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]>
333 lines
15 KiB
TypeScript
333 lines
15 KiB
TypeScript
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>
|
||
);
|
||
}
|