Files
Wabenhain/src/app/admin/bestellungen/page.tsx
T
Nick SzaunigandClaude Opus 5.5 dc1523aafd fix(orders): cancelled orders cannot be reopened (stock restocked twice)
After refund + cancellation the stock rose by 2 instead of 1. Cause:
the status form allowed moving an order out of STORNIERT. Cancelling
restocks the goods and refunds the payment; reopening did neither in
reverse, and the next cancellation passed the "not already cancelled"
claim again and restocked a second time (the refund was not repeated
because the payment was already REFUNDED, so only the stock showed it).

STORNIERT is now a final state:
- isOrderStatusChangeAllowed() in lib/inventory.ts next to the other
  cancel rules, with tests
- updateOrderStatusAction rejects the change up front with a clear
  message and moves non-cancel statuses with an atomic
  "status <> STORNIERT" update, so a cancellation from a second tab
  cannot be undone in between
- the order list and detail page no longer offer the status select for
  cancelled orders

Existing stock that was restocked twice has to be corrected by hand.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-27 00:45:31 +02:00

203 lines
9.0 KiB
TypeScript

import Link from "next/link";
import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link";
import { formatPrice, formatDate } from "@/lib/format";
import { OrderStatusBadge } from "@/components/account-ui";
import { updateOrderStatusAction } from "../actions";
import type { OrderStatus } from "@/generated/prisma/client";
import { insensitiveContains } from "@/lib/prisma-search";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
import { isArchiveView } from "@/lib/archive";
import { ensureAutoArchived } from "@/lib/archive-runner";
import { AdminArchiveTabs, AdminArchiveToggle } from "@/components/admin-archive";
import { AdminEmptyState, AdminPageHeader, AdminSearchForm } from "@/components/admin-list";
export default async function AdminOrdersPage(props: PageProps<"/admin/bestellungen">) {
const t = getDictionary(await getLocale());
const statusLabels = t.orderStatusBadge;
const { q, status, ansicht } = await props.searchParams;
const query = typeof q === "string" ? q.trim() : "";
const statusFilter = typeof status === "string" && status in statusLabels ? (status as OrderStatus) : undefined;
const isArchive = isArchiveView(ansicht);
await ensureAutoArchived("orders");
const filterWhere = {
...(statusFilter ? { status: statusFilter } : {}),
...(query
? {
OR: [
{ orderNumber: insensitiveContains(query) },
{ email: insensitiveContains(query) },
{ lastName: insensitiveContains(query) },
],
}
: {}),
};
const [orders, activeCount, archivedCount] = await Promise.all([
prisma.order.findMany({
where: { ...filterWhere, archivedAt: isArchive ? { not: null } : null },
orderBy: { createdAt: "desc" },
take: 100,
include: { items: true },
}),
prisma.order.count({ where: { ...filterWhere, archivedAt: null } }),
prisma.order.count({ where: { ...filterWhere, archivedAt: { not: null } } }),
]);
return (
<div>
<BackLink href="/admin" label={t.adminOrdersPage.backToDashboard} />
<AdminPageHeader title={t.adminOrdersPage.title} />
{/* Die DHL-Aktionen leiten bei einer nicht mehr auffindbaren Bestellung
hierher zurueck (admin/actions/dhl.ts). Ohne diese Ausgabe sah das
aus wie ein folgenloser Klick. */}
<AdminArchiveTabs
basePath="/admin/bestellungen"
isArchive={isArchive}
counts={{ active: activeCount, archived: archivedCount }}
keepParams={{ q: query, status: statusFilter ?? "" }}
labels={{
ariaLabel: t.adminArchive.tabsAriaLabel,
active: t.adminArchive.tabActive,
archive: t.adminArchive.tabArchive,
}}
/>
{isArchive ? <p className="mb-6 text-xs text-ink/60 max-w-2xl">{t.adminArchive.autoRuleOrders}</p> : null}
<AdminSearchForm
query={query}
label={t.adminOrdersPage.searchLabel}
placeholder={t.adminOrdersPage.searchPlaceholder}
submitLabel={t.adminOrdersPage.filterButton}
resetHref={isArchive ? "/admin/bestellungen?ansicht=archiv" : "/admin/bestellungen"}
resetLabel={t.adminOrdersPage.resetFilter}
hiddenFields={isArchive ? { ansicht: "archiv" } : {}}
isFiltered={Boolean(query || statusFilter)}
>
<label className="text-sm">
<span className="block text-ink/65 text-xs mb-1">{t.adminOrdersPage.statusLabel}</span>
<select
name="status"
defaultValue={statusFilter ?? ""}
className="border border-ink/20 bg-background px-3 py-2 text-sm"
>
<option value="">{t.adminOrdersPage.statusAll}</option>
{Object.entries(statusLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</label>
</AdminSearchForm>
<div className="overflow-x-auto bg-background border border-ink/10">
<table className="w-full text-sm">
<thead className="text-left text-ink/65 border-b border-ink/10">
<tr>
<th className="p-4">{t.adminOrdersPage.colNumber}</th>
<th className="p-4">{t.adminOrdersPage.colCustomer}</th>
<th className="p-4">{t.adminOrdersPage.colDate}</th>
<th className="p-4">{t.adminOrdersPage.colItems}</th>
<th className="p-4">{t.adminOrdersPage.colTotal}</th>
<th className="p-4">{t.adminOrdersPage.colStatus}</th>
<th className="p-4">{t.adminOrdersPage.colFulfillment}</th>
<th className="p-4" />
</tr>
</thead>
<tbody className="divide-y divide-ink/10">
{orders.map((order) => (
<tr key={order.id} className="transition-colors hover:bg-ink/[0.03]">
<td className="p-4 text-heading whitespace-nowrap">
<Link href={`/admin/bestellungen/${order.id}`} className="link-draw">
{order.orderNumber}
</Link>
{order.giftMessage ? (
<span
className="ml-1.5 inline-flex align-[-2px] text-honey-dark"
title={t.adminOrdersPage.giftOrderTitle}
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" aria-hidden="true">
<rect x="3.5" y="8.5" width="17" height="4" />
<path d="M5 12.5v8h14v-8M12 8.5v12M12 8.5c-1.5-3.5-5-4-5-1.5 0 1.5 2.5 1.5 5 1.5zm0 0c1.5-3.5 5-4 5-1.5 0 1.5-2.5 1.5-5 1.5z" />
</svg>
<span className="sr-only">{t.adminOrdersPage.giftOrderTitle}</span>
</span>
) : null}
</td>
<td className="p-4">
{order.firstName} {order.lastName}
<br />
<span className="text-ink/65 text-xs">{order.email}</span>
</td>
<td className="p-4 whitespace-nowrap tabular-nums">{formatDate(order.createdAt)}</td>
<td className="p-4 tabular-nums">{order.items.length}</td>
<td className="p-4 whitespace-nowrap tabular-nums">{formatPrice(order.totalCents, order.currency)}</td>
<td className="p-4">
<OrderStatusBadge status={order.status} label={statusLabels[order.status] ?? order.status} />
{/* Storniert ist ein Endzustand (isOrderStatusChangeAllowed) — keine Auswahl anbieten, die der Server ablehnt. */}
{order.status !== "STORNIERT" ? (
<form action={updateOrderStatusAction} className="mt-2 flex items-center gap-2">
<input type="hidden" name="orderId" value={order.id} />
<select
name="status"
defaultValue={order.status}
aria-label={t.adminOrdersPage.statusLabel}
className="border border-ink/20 bg-background px-2 py-1.5 text-xs"
>
{Object.entries(statusLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
<button
type="submit"
className="border border-heading px-3 py-1.5 text-xs text-heading transition-colors duration-300 hover:bg-forest hover:text-cream"
>
{t.adminOrdersPage.save}
</button>
</form>
) : null}
</td>
<td className="p-4">
{order.fulfillmentMethod === "VERSAND" ? t.adminOrdersPage.shipping : t.adminOrdersPage.pickup}
</td>
<td className="p-4 whitespace-nowrap">
<AdminArchiveToggle
kind="orders"
id={order.id}
archivedAt={order.archivedAt}
archivedManually={order.archivedManually}
labels={{
archive: t.adminArchive.archive,
restore: t.adminArchive.restore,
archivedOn: t.adminArchive.archivedOn,
manualHint: t.adminArchive.manualHint,
}}
/>
</td>
</tr>
))}
</tbody>
</table>
{orders.length === 0 ? (
<AdminEmptyState
message={
query || statusFilter
? t.adminOrdersPage.noResultsFiltered
: isArchive
? t.adminArchive.emptyArchive
: t.adminOrdersPage.noOrdersYet
}
/>
) : null}
</div>
</div>
);
}