Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 3m24s
Abgeschlossene Eintraege wandern automatisch in einen Tab "Archiv", damit die aktiven Listen nur zeigen, was noch zu tun ist: - Bestellungen: versendet/abgeholt/storniert, keine offene Retoure, seit einem Monat nicht bearbeitet - Retouren: angenommen/abgelehnt/erstattet, seit einem Monat nicht bearbeitet - Produkte: seit einem Monat Bestand 0 (nur Admin-Sicht, Shop unveraendert) Die Automatik laeuft beim Oeffnen der jeweiligen Liste (hoechstens alle 5 Minuten) und holt Eintraege zurueck, sobald sie wieder offen sind. Zusaetzlich "Archivieren"/"Wiederherstellen" per Hand; von Hand archivierte Eintraege fasst die Automatik nicht an. Alle Verschiebungen landen im Audit-Log. Neue Felder archivedAt/archivedManually samt Index an orders, return_requests und products (Migration 20260925010000_archive_fields). Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
205 lines
8.6 KiB
TypeScript
205 lines
8.6 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";
|
|
|
|
export default async function AdminOrdersPage(props: PageProps<"/admin/bestellungen">) {
|
|
const t = getDictionary(await getLocale());
|
|
const statusLabels = t.orderStatusBadge;
|
|
const { q, status, error, message, 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} />
|
|
<h1 className="text-2xl text-forest mb-8">{t.adminOrdersPage.title}</h1>
|
|
{/* 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. */}
|
|
{message ? (
|
|
<p className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">{message}</p>
|
|
) : null}
|
|
{error ? (
|
|
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
|
) : null}
|
|
|
|
<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}
|
|
|
|
<form method="GET" className="flex flex-wrap items-end gap-3 mb-6">
|
|
{isArchive ? <input type="hidden" name="ansicht" value="archiv" /> : null}
|
|
<label className="text-sm">
|
|
<span className="block text-ink/65 text-xs mb-1">{t.adminOrdersPage.searchLabel}</span>
|
|
<input
|
|
type="text"
|
|
name="q"
|
|
defaultValue={query}
|
|
placeholder={t.adminOrdersPage.searchPlaceholder}
|
|
className="border border-ink/20 bg-background px-3 py-2 text-sm w-64"
|
|
/>
|
|
</label>
|
|
<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>
|
|
<button type="submit" className="bg-forest text-cream px-4 py-2 text-sm">
|
|
{t.adminOrdersPage.filterButton}
|
|
</button>
|
|
{query || statusFilter ? (
|
|
<Link
|
|
href={isArchive ? "/admin/bestellungen?ansicht=archiv" : "/admin/bestellungen"}
|
|
className="text-sm text-ink/65 underline"
|
|
>
|
|
{t.adminOrdersPage.resetFilter}
|
|
</Link>
|
|
) : null}
|
|
</form>
|
|
|
|
<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-forest">
|
|
<Link href={`/admin/bestellungen/${order.id}`} className="underline">
|
|
{order.orderNumber}
|
|
</Link>
|
|
{order.giftMessage ? (
|
|
<span className="ml-1.5" title={t.adminOrdersPage.giftOrderTitle}>
|
|
🎁
|
|
</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">{formatDate(order.createdAt)}</td>
|
|
<td className="p-4">{order.items.length}</td>
|
|
<td className="p-4">{formatPrice(order.totalCents, order.currency)}</td>
|
|
<td className="p-4">
|
|
<OrderStatusBadge status={order.status} label={statusLabels[order.status] ?? order.status} />
|
|
<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}
|
|
className="border border-ink/20 bg-background px-2 py-1 text-xs"
|
|
>
|
|
{Object.entries(statusLabels).map(([value, label]) => (
|
|
<option key={value} value={value}>
|
|
{label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<button type="submit" className="text-xs text-forest underline">
|
|
{t.adminOrdersPage.save}
|
|
</button>
|
|
</form>
|
|
</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 ? (
|
|
<p className="p-6 text-ink/65">
|
|
{query || statusFilter
|
|
? t.adminOrdersPage.noResultsFiltered
|
|
: isArchive
|
|
? t.adminArchive.emptyArchive
|
|
: t.adminOrdersPage.noOrdersYet}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|