feat: Archiv-Tab fuer Bestellungen, Retouren und Produkte im Admin
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]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-25 04:05:10 +02:00
1 parent 01eaca39dc
commit fb9c2353c6
14 files changed
+772 -54

No files matched your search

@@ -0,0 +1,31 @@
-- Archiv fuer Bestellungen, Retouren und Produkte (siehe lib/archive.ts).
--
-- archivedAt gesetzt = Eintrag erscheint im Admin-Tab "Archiv" statt in der
-- aktiven Liste. Die Automatik archiviert abgeschlossene Eintraege nach einem
-- Monat ohne Bearbeitung (Produkte: Bestand 0) und holt sie zurueck, sobald
-- sie wieder offen sind. archivedManually markiert Eintraege, die jemand
-- von Hand archiviert hat — die fasst die Automatik nicht an.
--
-- Nullable bzw. mit Default: bestehende Zeilen gelten als aktiv; der erste
-- Aufruf der jeweiligen Admin-Liste archiviert faellige Altbestaende.
-- AlterTable
ALTER TABLE "orders" ADD COLUMN "archivedAt" TIMESTAMP(3),
ADD COLUMN "archivedManually" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "return_requests" ADD COLUMN "archivedAt" TIMESTAMP(3),
ADD COLUMN "archivedManually" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "products" ADD COLUMN "archivedAt" TIMESTAMP(3),
ADD COLUMN "archivedManually" BOOLEAN NOT NULL DEFAULT false;
-- CreateIndex
CREATE INDEX "orders_archivedAt_idx" ON "orders"("archivedAt");
-- CreateIndex
CREATE INDEX "return_requests_archivedAt_idx" ON "return_requests"("archivedAt");
-- CreateIndex
CREATE INDEX "products_archivedAt_idx" ON "products"("archivedAt");
+26
View File
@@ -255,6 +255,13 @@ model Product {
/// Liste von Bild-URLs als JSON-Array, siehe tasteTags-Kommentar oben. /// Liste von Bild-URLs als JSON-Array, siehe tasteTags-Kommentar oben.
images Json @default("[]") images Json @default("[]")
isActive Boolean @default(true) isActive Boolean @default(true)
/// Archiv (siehe lib/archive.ts): gesetzt = im Admin-Tab "Archiv" statt in
/// der aktiven Liste. Wird beim Oeffnen der Admin-Liste automatisch nach
/// den Regeln dort gesetzt und wieder geloescht; archivedManually
/// kennzeichnet einen Klick auf "Archivieren", den die Automatik nicht
/// rueckgaengig machen darf.
archivedAt DateTime?
archivedManually Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -266,6 +273,7 @@ model Product {
wishlistItems WishlistItem[] wishlistItems WishlistItem[]
stockNotifications StockNotification[] stockNotifications StockNotification[]
@@index([archivedAt])
@@map("products") @@map("products")
} }
@@ -412,6 +420,14 @@ model Order {
/// Verpacken von Hand beigelegt (siehe Admin-Bestelldetail). /// Verpacken von Hand beigelegt (siehe Admin-Bestelldetail).
giftMessage String? giftMessage String?
/// Archiv (siehe lib/archive.ts): gesetzt = im Admin-Tab "Archiv" statt in
/// der aktiven Liste. Wird beim Oeffnen der Admin-Liste automatisch nach
/// den Regeln dort gesetzt und wieder geloescht; archivedManually
/// kennzeichnet einen Klick auf "Archivieren", den die Automatik nicht
/// rueckgaengig machen darf.
archivedAt DateTime?
archivedManually Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -434,6 +450,8 @@ model Order {
@@index([createdAt]) @@index([createdAt])
/// Nutzungszaehlung eines Rabattcodes beim Checkout (Order.count nach discountCodeId). /// Nutzungszaehlung eines Rabattcodes beim Checkout (Order.count nach discountCodeId).
@@index([discountCodeId]) @@index([discountCodeId])
/// Tab-Filter Aktiv/Archiv im Admin-Bereich.
@@index([archivedAt])
@@map("orders") @@map("orders")
} }
@@ -451,11 +469,19 @@ model ReturnRequest {
orderId String @unique orderId String @unique
reason String reason String
status ReturnStatus @default(ANGEFRAGT) status ReturnStatus @default(ANGEFRAGT)
/// Archiv (siehe lib/archive.ts): gesetzt = im Admin-Tab "Archiv" statt in
/// der aktiven Liste. Wird beim Oeffnen der Admin-Liste automatisch nach
/// den Regeln dort gesetzt und wieder geloescht; archivedManually
/// kennzeichnet einen Klick auf "Archivieren", den die Automatik nicht
/// rueckgaengig machen darf.
archivedAt DateTime?
archivedManually Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
@@index([archivedAt])
@@map("return_requests") @@map("return_requests")
} }
+1
View File
@@ -14,3 +14,4 @@ export * from "./actions/discount-codes";
export * from "./actions/content"; export * from "./actions/content";
export * from "./actions/apicore-sync"; export * from "./actions/apicore-sync";
export * from "./actions/dhl"; export * from "./actions/dhl";
export * from "./actions/archive";
+63
View File
@@ -0,0 +1,63 @@
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log";
import type { ArchiveKind } from "@/lib/archive";
import { requireAdmin, str } from "./_shared";
const LIST_PATH: Record<ArchiveKind, string> = {
orders: "/admin/bestellungen",
returns: "/admin/retouren",
products: "/admin/produkte",
};
const ENTITY_TYPE: Record<ArchiveKind, string> = {
orders: "Order",
returns: "ReturnRequest",
products: "Product",
};
function isArchiveKind(value: string): value is ArchiveKind {
return value in LIST_PATH;
}
/**
* Archiviert einen Eintrag von Hand oder holt ihn zurueck.
*
* Archivieren setzt archivedManually, damit die Automatik den Eintrag nicht
* beim naechsten Seitenaufruf wieder in die aktive Liste holt.
* Wiederherstellen setzt die Markierung zurueck; da @updatedAt dabei die
* Bearbeitungszeit erneuert, bleibt der Eintrag einen weiteren Monat aktiv.
*/
export async function setArchivedAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const kind = str(formData, "kind");
const id = str(formData, "id");
const archive = str(formData, "mode") === "archive";
if (!isArchiveKind(kind) || !id) return;
const data = archive
? { archivedAt: new Date(), archivedManually: true }
: { archivedAt: null, archivedManually: false };
// updateMany statt update: eine nicht (mehr) vorhandene ID ist dann kein
// Fehler, sondern schlicht count 0 — z.B. nach dem Loeschen in einem
// zweiten Tab.
const where = { id };
const result =
kind === "orders"
? await prisma.order.updateMany({ where, data })
: kind === "returns"
? await prisma.returnRequest.updateMany({ where, data })
: await prisma.product.updateMany({ where, data });
if (result.count === 0) return;
await logAdminAction(admin, {
action: archive ? "archive.manual_archive" : "archive.manual_restore",
entityType: ENTITY_TYPE[kind],
entityId: id,
});
revalidatePath(LIST_PATH[kind]);
}
+68 -20
View File
@@ -8,31 +8,43 @@ import type { OrderStatus } from "@/generated/prisma/client";
import { insensitiveContains } from "@/lib/prisma-search"; import { insensitiveContains } from "@/lib/prisma-search";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; 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">) { export default async function AdminOrdersPage(props: PageProps<"/admin/bestellungen">) {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const statusLabels = t.orderStatusBadge; const statusLabels = t.orderStatusBadge;
const { q, status, error, message } = await props.searchParams; const { q, status, error, message, ansicht } = await props.searchParams;
const query = typeof q === "string" ? q.trim() : ""; const query = typeof q === "string" ? q.trim() : "";
const statusFilter = typeof status === "string" && status in statusLabels ? (status as OrderStatus) : undefined; const statusFilter = typeof status === "string" && status in statusLabels ? (status as OrderStatus) : undefined;
const isArchive = isArchiveView(ansicht);
const orders = await prisma.order.findMany({ await ensureAutoArchived("orders");
where: {
...(statusFilter ? { status: statusFilter } : {}), const filterWhere = {
...(query ...(statusFilter ? { status: statusFilter } : {}),
? { ...(query
OR: [ ? {
{ orderNumber: insensitiveContains(query) }, OR: [
{ email: insensitiveContains(query) }, { orderNumber: insensitiveContains(query) },
{ lastName: insensitiveContains(query) }, { email: insensitiveContains(query) },
], { lastName: insensitiveContains(query) },
} ],
: {}), }
}, : {}),
orderBy: { createdAt: "desc" }, };
take: 100,
include: { items: true }, 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 ( return (
<div> <div>
@@ -48,7 +60,21 @@ export default async function AdminOrdersPage(props: PageProps<"/admin/bestellun
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p> <p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null} ) : 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"> <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"> <label className="text-sm">
<span className="block text-ink/65 text-xs mb-1">{t.adminOrdersPage.searchLabel}</span> <span className="block text-ink/65 text-xs mb-1">{t.adminOrdersPage.searchLabel}</span>
<input <input
@@ -78,7 +104,10 @@ export default async function AdminOrdersPage(props: PageProps<"/admin/bestellun
{t.adminOrdersPage.filterButton} {t.adminOrdersPage.filterButton}
</button> </button>
{query || statusFilter ? ( {query || statusFilter ? (
<Link href="/admin/bestellungen" className="text-sm text-ink/65 underline"> <Link
href={isArchive ? "/admin/bestellungen?ansicht=archiv" : "/admin/bestellungen"}
className="text-sm text-ink/65 underline"
>
{t.adminOrdersPage.resetFilter} {t.adminOrdersPage.resetFilter}
</Link> </Link>
) : null} ) : null}
@@ -95,6 +124,7 @@ export default async function AdminOrdersPage(props: PageProps<"/admin/bestellun
<th className="p-4">{t.adminOrdersPage.colTotal}</th> <th className="p-4">{t.adminOrdersPage.colTotal}</th>
<th className="p-4">{t.adminOrdersPage.colStatus}</th> <th className="p-4">{t.adminOrdersPage.colStatus}</th>
<th className="p-4">{t.adminOrdersPage.colFulfillment}</th> <th className="p-4">{t.adminOrdersPage.colFulfillment}</th>
<th className="p-4" />
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-ink/10"> <tbody className="divide-y divide-ink/10">
@@ -141,13 +171,31 @@ export default async function AdminOrdersPage(props: PageProps<"/admin/bestellun
<td className="p-4"> <td className="p-4">
{order.fulfillmentMethod === "VERSAND" ? t.adminOrdersPage.shipping : t.adminOrdersPage.pickup} {order.fulfillmentMethod === "VERSAND" ? t.adminOrdersPage.shipping : t.adminOrdersPage.pickup}
</td> </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> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
{orders.length === 0 ? ( {orders.length === 0 ? (
<p className="p-6 text-ink/65"> <p className="p-6 text-ink/65">
{query || statusFilter ? t.adminOrdersPage.noResultsFiltered : t.adminOrdersPage.noOrdersYet} {query || statusFilter
? t.adminOrdersPage.noResultsFiltered
: isArchive
? t.adminArchive.emptyArchive
: t.adminOrdersPage.noOrdersYet}
</p> </p>
) : null} ) : null}
</div> </div>
+52 -11
View File
@@ -7,20 +7,32 @@ import { deleteProductAction } from "../actions";
import { ApiCoreSyncPanel, ApiCoreSyncResult } from "@/components/apicore-sync-panel"; import { ApiCoreSyncPanel, ApiCoreSyncResult } from "@/components/apicore-sync-panel";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; 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 AdminProductsPage(props: PageProps<"/admin/produkte">) { export default async function AdminProductsPage(props: PageProps<"/admin/produkte">) {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const searchParams = await props.searchParams; const searchParams = await props.searchParams;
const products = await prisma.product.findMany({ const isArchive = isArchiveView(searchParams.ansicht);
include: {
harvest: true, await ensureAutoArchived("products");
batch: true,
inventory: true, const [products, activeCount, archivedCount] = await Promise.all([
// Entscheidet, ob geloescht werden darf — siehe Loeschknopf unten. prisma.product.findMany({
_count: { select: { orderItems: true } }, where: { archivedAt: isArchive ? { not: null } : null },
}, include: {
orderBy: { createdAt: "desc" }, harvest: true,
}); batch: true,
inventory: true,
// Entscheidet, ob geloescht werden darf — siehe Loeschknopf unten.
_count: { select: { orderItems: true } },
},
orderBy: { createdAt: "desc" },
}),
prisma.product.count({ where: { archivedAt: null } }),
prisma.product.count({ where: { archivedAt: { not: null } } }),
]);
return ( return (
<div> <div>
@@ -34,6 +46,19 @@ export default async function AdminProductsPage(props: PageProps<"/admin/produkt
<ApiCoreSyncResult searchParams={searchParams} /> <ApiCoreSyncResult searchParams={searchParams} />
<AdminArchiveTabs
basePath="/admin/produkte"
isArchive={isArchive}
counts={{ active: activeCount, archived: archivedCount }}
keepParams={{}}
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.autoRuleProducts}</p> : null}
<div className="overflow-x-auto bg-background border border-ink/10"> <div className="overflow-x-auto bg-background border border-ink/10">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="text-left text-ink/65 border-b border-ink/10"> <thead className="text-left text-ink/65 border-b border-ink/10">
@@ -75,6 +100,20 @@ export default async function AdminProductsPage(props: PageProps<"/admin/produkt
<Link href={`/admin/produkte/${product.id}`} className="text-forest underline mr-4"> <Link href={`/admin/produkte/${product.id}`} className="text-forest underline mr-4">
{t.adminProductsPage.edit} {t.adminProductsPage.edit}
</Link> </Link>
<span className="mr-4">
<AdminArchiveToggle
kind="products"
id={product.id}
archivedAt={product.archivedAt}
archivedManually={product.archivedManually}
labels={{
archive: t.adminArchive.archive,
restore: t.adminArchive.restore,
archivedOn: t.adminArchive.archivedOn,
manualHint: t.adminArchive.manualHint,
}}
/>
</span>
{/* Loeschen nur, solange nie bestellt: OrderItem verweist mit {/* Loeschen nur, solange nie bestellt: OrderItem verweist mit
onDelete: Restrict auf das Produkt, damit gestellte onDelete: Restrict auf das Produkt, damit gestellte
Rechnungen ihren Bezug behalten (siehe Rechnungen ihren Bezug behalten (siehe
@@ -100,7 +139,9 @@ export default async function AdminProductsPage(props: PageProps<"/admin/produkt
</tbody> </tbody>
</table> </table>
{products.length === 0 ? ( {products.length === 0 ? (
<p className="p-6 text-ink/65">{t.adminProductsPage.noProductsYet}</p> <p className="p-6 text-ink/65">
{isArchive ? t.adminArchive.emptyArchive : t.adminProductsPage.noProductsYet}
</p>
) : null} ) : null}
</div> </div>
+72 -23
View File
@@ -6,6 +6,9 @@ import { insensitiveContains } from "@/lib/prisma-search";
import { updateReturnRequestStatusAction, refundReturnRequestAction } from "./actions"; import { updateReturnRequestStatusAction, refundReturnRequestAction } from "./actions";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; 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 AdminRetourenPage(props: PageProps<"/admin/retouren">) { export default async function AdminRetourenPage(props: PageProps<"/admin/retouren">) {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
@@ -15,35 +18,58 @@ export default async function AdminRetourenPage(props: PageProps<"/admin/retoure
ABGELEHNT: t.adminReturnsPage.statusRejected, ABGELEHNT: t.adminReturnsPage.statusRejected,
ERSTATTET: t.adminReturnsPage.statusRefunded, ERSTATTET: t.adminReturnsPage.statusRefunded,
}; };
const { q } = await props.searchParams; const { q, ansicht } = await props.searchParams;
const query = typeof q === "string" ? q.trim() : ""; const query = typeof q === "string" ? q.trim() : "";
const isArchive = isArchiveView(ansicht);
const returnRequests = await prisma.returnRequest.findMany({ await ensureAutoArchived("returns");
where: query
? { const filterWhere = query
order: { ? {
OR: [ order: {
{ orderNumber: insensitiveContains(query) }, OR: [
{ firstName: insensitiveContains(query) }, { orderNumber: insensitiveContains(query) },
{ lastName: insensitiveContains(query) }, { firstName: insensitiveContains(query) },
{ email: insensitiveContains(query) }, { lastName: insensitiveContains(query) },
], { email: insensitiveContains(query) },
}, ],
} },
: undefined, }
orderBy: { createdAt: "desc" }, : {};
take: 100,
include: { const [returnRequests, activeCount, archivedCount] = await Promise.all([
order: { include: { items: true } }, prisma.returnRequest.findMany({
}, where: { ...filterWhere, archivedAt: isArchive ? { not: null } : null },
}); orderBy: { createdAt: "desc" },
take: 100,
include: {
order: { include: { items: true } },
},
}),
prisma.returnRequest.count({ where: { ...filterWhere, archivedAt: null } }),
prisma.returnRequest.count({ where: { ...filterWhere, archivedAt: { not: null } } }),
]);
return ( return (
<div> <div>
<BackLink href="/admin" label={t.adminReturnsPage.backToDashboard} /> <BackLink href="/admin" label={t.adminReturnsPage.backToDashboard} />
<h1 className="text-2xl text-forest mb-8">{t.adminReturnsPage.title}</h1> <h1 className="text-2xl text-forest mb-8">{t.adminReturnsPage.title}</h1>
<AdminArchiveTabs
basePath="/admin/retouren"
isArchive={isArchive}
counts={{ active: activeCount, archived: archivedCount }}
keepParams={{ q: query }}
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.autoRuleReturns}</p> : null}
<form method="GET" className="flex flex-wrap items-end gap-3 mb-6"> <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"> <label className="text-sm">
<span className="block text-ink/65 text-xs mb-1">{t.adminReturnsPage.searchLabel}</span> <span className="block text-ink/65 text-xs mb-1">{t.adminReturnsPage.searchLabel}</span>
<input <input
@@ -58,7 +84,10 @@ export default async function AdminRetourenPage(props: PageProps<"/admin/retoure
{t.adminReturnsPage.filterButton} {t.adminReturnsPage.filterButton}
</button> </button>
{query ? ( {query ? (
<Link href="/admin/retouren" className="text-sm text-ink/65 underline"> <Link
href={isArchive ? "/admin/retouren?ansicht=archiv" : "/admin/retouren"}
className="text-sm text-ink/65 underline"
>
{t.adminReturnsPage.resetFilter} {t.adminReturnsPage.resetFilter}
</Link> </Link>
) : null} ) : null}
@@ -75,7 +104,23 @@ export default async function AdminRetourenPage(props: PageProps<"/admin/retoure
</p> </p>
<p className="text-xs text-ink/65 mt-1">{formatDate(request.createdAt)}</p> <p className="text-xs text-ink/65 mt-1">{formatDate(request.createdAt)}</p>
</div> </div>
<p className="text-sm">{statusLabels[request.status] ?? request.status}</p> <div className="text-right">
<p className="text-sm">{statusLabels[request.status] ?? request.status}</p>
<div className="mt-2">
<AdminArchiveToggle
kind="returns"
id={request.id}
archivedAt={request.archivedAt}
archivedManually={request.archivedManually}
labels={{
archive: t.adminArchive.archive,
restore: t.adminArchive.restore,
archivedOn: t.adminArchive.archivedOn,
manualHint: t.adminArchive.manualHint,
}}
/>
</div>
</div>
</div> </div>
<ul className="mt-4 text-sm text-ink/70 space-y-1"> <ul className="mt-4 text-sm text-ink/70 space-y-1">
@@ -119,7 +164,11 @@ export default async function AdminRetourenPage(props: PageProps<"/admin/retoure
))} ))}
{returnRequests.length === 0 ? ( {returnRequests.length === 0 ? (
<p className="text-ink/65"> <p className="text-ink/65">
{query ? t.adminReturnsPage.noResultsFiltered : t.adminReturnsPage.noRequestsYet} {query
? t.adminReturnsPage.noResultsFiltered
: isArchive
? t.adminArchive.emptyArchive
: t.adminReturnsPage.noRequestsYet}
</p> </p>
) : null} ) : null}
</div> </div>
+80
View File
@@ -0,0 +1,80 @@
import Link from "next/link";
import { setArchivedAction } from "@/app/admin/actions";
import { ARCHIVE_VIEW_PARAM, ARCHIVE_VIEW_VALUE, type ArchiveKind } from "@/lib/archive";
import { formatDate } from "@/lib/format";
/**
* Tabs "Aktiv" / "Archiv" fuer die Admin-Listen Bestellungen, Retouren und
* Produkte. Reine Links statt Client-State: der Tab steht in der URL
* (?ansicht=archiv), bleibt also beim Neuladen, nach einer Server Action und
* beim Teilen eines Links erhalten. Uebrige Filter (Suche, Status) bleiben
* beim Wechsel stehen.
*/
export function AdminArchiveTabs({
basePath,
isArchive,
counts,
keepParams,
labels,
}: {
basePath: string;
isArchive: boolean;
counts: { active: number; archived: number };
keepParams: Record<string, string>;
labels: { ariaLabel: string; active: string; archive: string };
}) {
const hrefFor = (archive: boolean): string => {
const params = new URLSearchParams(Object.entries(keepParams).filter(([, value]) => value !== ""));
if (archive) params.set(ARCHIVE_VIEW_PARAM, ARCHIVE_VIEW_VALUE);
const query = params.toString();
return query ? `${basePath}?${query}` : basePath;
};
const tabClass = (selected: boolean): string =>
`px-4 py-2 text-sm border-b-2 -mb-px transition-colors ${
selected ? "border-forest text-forest" : "border-transparent text-ink/60 hover:text-forest"
}`;
return (
<nav aria-label={labels.ariaLabel} className="flex gap-1 border-b border-ink/10 mb-6">
<Link href={hrefFor(false)} aria-current={!isArchive ? "page" : undefined} className={tabClass(!isArchive)}>
{labels.active} <span className="text-xs text-ink/50">({counts.active})</span>
</Link>
<Link href={hrefFor(true)} aria-current={isArchive ? "page" : undefined} className={tabClass(isArchive)}>
{labels.archive} <span className="text-xs text-ink/50">({counts.archived})</span>
</Link>
</nav>
);
}
/** Knopf "Archivieren" bzw. "Wiederherstellen" samt Archivdatum. */
export function AdminArchiveToggle({
kind,
id,
archivedAt,
archivedManually,
labels,
}: {
kind: ArchiveKind;
id: string;
archivedAt: Date | null;
archivedManually: boolean;
labels: { archive: string; restore: string; archivedOn: (date: string) => string; manualHint: string };
}) {
return (
<form action={setArchivedAction} className="inline">
<input type="hidden" name="kind" value={kind} />
<input type="hidden" name="id" value={id} />
<input type="hidden" name="mode" value={archivedAt ? "restore" : "archive"} />
{archivedAt ? (
<span className="block text-xs text-ink/55 mb-1">
{labels.archivedOn(formatDate(archivedAt))}
{archivedManually ? ` · ${labels.manualHint}` : null}
</span>
) : null}
<button type="submit" className="text-xs text-ink/65 underline hover:text-forest">
{archivedAt ? labels.restore : labels.archive}
</button>
</form>
);
}
+16
View File
@@ -927,6 +927,22 @@ export const de: Dictionary = {
unknown: "Status unbekannt", unknown: "Status unbekannt",
}, },
}, },
adminArchive: {
tabsAriaLabel: "Ansicht",
tabActive: "Aktiv",
tabArchive: "Archiv",
archive: "Archivieren",
restore: "Wiederherstellen",
archivedOn: (date) => `Archiviert am ${date}`,
manualHint: "von Hand",
autoRuleOrders:
"Versendete, abgeholte und stornierte Bestellungen ohne offene Retoure landen nach einem Monat ohne Bearbeitung automatisch hier.",
autoRuleReturns:
"Angenommene, abgelehnte und erstattete Retouren landen nach einem Monat ohne Bearbeitung automatisch hier.",
autoRuleProducts:
"Produkte, die seit über einem Monat Bestand 0 haben, landen automatisch hier und kehren zurück, sobald wieder Bestand da ist. Im Shop bleiben sie unverändert sichtbar.",
emptyArchive: "Das Archiv ist leer.",
},
errorPage: { errorPage: {
eyebrow: "Fehler", eyebrow: "Fehler",
title: "Etwas ist schiefgelaufen.", title: "Etwas ist schiefgelaufen.",
+15
View File
@@ -915,6 +915,21 @@ export const en: Dictionary = {
unknown: "Status unknown", unknown: "Status unknown",
}, },
}, },
adminArchive: {
tabsAriaLabel: "View",
tabActive: "Active",
tabArchive: "Archive",
archive: "Archive",
restore: "Restore",
archivedOn: (date) => `Archived on ${date}`,
manualHint: "manually",
autoRuleOrders:
"Shipped, picked-up and cancelled orders without an open return move here automatically after one month without changes.",
autoRuleReturns: "Accepted, rejected and refunded returns move here automatically after one month without changes.",
autoRuleProducts:
"Products with zero stock for more than a month move here automatically and return once they are back in stock. They stay visible in the shop.",
emptyArchive: "The archive is empty.",
},
errorPage: { errorPage: {
eyebrow: "Error", eyebrow: "Error",
title: "Something went wrong.", title: "Something went wrong.",
+13
View File
@@ -603,6 +603,19 @@ export interface Dictionary {
unknown: string; unknown: string;
}; };
}; };
adminArchive: {
tabsAriaLabel: string;
tabActive: string;
tabArchive: string;
archive: string;
restore: string;
archivedOn: (date: string) => string;
manualHint: string;
autoRuleOrders: string;
autoRuleReturns: string;
autoRuleProducts: string;
emptyArchive: string;
};
errorPage: { errorPage: {
eyebrow: string; eyebrow: string;
title: string; title: string;
+50
View File
@@ -0,0 +1,50 @@
/**
* Fuehrt die Archiv-Automatik (lib/archive.ts) beim Oeffnen einer Admin-Liste
* aus. Bewusst KEINE Server Action ("use server" fehlt absichtlich): sonst
* waere das ein eigener, von aussen aufrufbarer POST-Endpunkt.
*/
import { prisma } from "@/lib/prisma";
import { getCurrentAdmin } from "@/lib/auth";
import { logAdminAction } from "@/lib/audit-log";
import { runAutoArchive, type ArchiveKind } from "@/lib/archive";
/**
* Die Regeln haengen an "ein Monat" — minutengenau muss das nicht sein. Die
* Sperre haelt die zwei UPDATEs aus jedem einzelnen Seitenaufruf heraus
* (Filtern, Blaettern, Zurueck nach einer Aktion).
*/
const MIN_INTERVAL_MS = 5 * 60 * 1000;
const lastRun = new Map<ArchiveKind, number>();
const ENTITY_TYPE: Record<ArchiveKind, string> = {
orders: "Order",
returns: "ReturnRequest",
products: "Product",
};
export async function ensureAutoArchived(kind: ArchiveKind): Promise<void> {
const now = Date.now();
if (now - (lastRun.get(kind) ?? 0) < MIN_INTERVAL_MS) return;
// Das Admin-Layout prueft bereits; hier zusaetzlich, weil diese Funktion
// schreibt und nie ohne angemeldeten Admin laufen soll.
const admin = await getCurrentAdmin();
if (!admin) return;
lastRun.set(kind, now);
try {
const result = await runAutoArchive(prisma, kind, new Date(now));
if (result.archived > 0 || result.restored > 0) {
await logAdminAction(admin, {
action: "archive.auto",
entityType: ENTITY_TYPE[kind],
detail: `automatisch: ${result.archived} archiviert, ${result.restored} wiederhergestellt`,
});
}
} catch (error: unknown) {
// Die Liste soll trotzdem erscheinen; beim naechsten Aufruf nach Ablauf
// der Sperre wird es erneut versucht.
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
console.error(`[archive] Automatik fuer "${kind}" fehlgeschlagen: ${message}`);
}
}
+136
View File
@@ -0,0 +1,136 @@
import { describe, it, expect, vi } from "vitest";
import {
ARCHIVE_AFTER_DAYS,
archiveCutoff,
orderArchiveWhere,
orderUnarchiveWhere,
productArchiveWhere,
productUnarchiveWhere,
returnArchiveWhere,
returnUnarchiveWhere,
runAutoArchive,
type ArchiveDb,
} from "./archive";
const NOW = new Date("2026-09-25T12:00:00Z");
function fakeDb(counts: { archived: number; restored: number }) {
const model = () => ({
updateMany: vi
.fn()
.mockResolvedValueOnce({ count: counts.archived })
.mockResolvedValueOnce({ count: counts.restored }),
});
return { order: model(), returnRequest: model(), product: model() };
}
describe("archiveCutoff", () => {
it("liegt genau einen Monat (30 Tage) vor jetzt", () => {
expect(ARCHIVE_AFTER_DAYS).toBe(30);
expect(archiveCutoff(NOW).toISOString()).toBe("2026-08-26T12:00:00.000Z");
});
});
describe("orderArchiveWhere", () => {
const where = orderArchiveWhere(archiveCutoff(NOW));
it("archiviert nur versendete, abgeholte und stornierte Bestellungen", () => {
expect(where.status).toEqual({ in: ["VERSENDET", "ABGEHOLT", "STORNIERT"] });
});
it("verlangt, dass die letzte Bearbeitung mehr als einen Monat zurueckliegt", () => {
expect(where.updatedAt).toEqual({ lte: archiveCutoff(NOW) });
expect(where.archivedAt).toBeNull();
});
it("laesst Bestellungen mit offener oder frisch bearbeiteter Retoure aktiv", () => {
expect(where.OR).toEqual([
{ returnRequest: { is: null } },
{
returnRequest: {
is: { status: { in: ["ANGENOMMEN", "ABGELEHNT", "ERSTATTET"] }, updatedAt: { lte: archiveCutoff(NOW) } },
},
},
]);
});
});
describe("orderUnarchiveWhere", () => {
it("holt nur automatisch archivierte Bestellungen zurueck, wenn Status oder Retoure wieder offen sind", () => {
expect(orderUnarchiveWhere()).toEqual({
archivedAt: { not: null },
archivedManually: false,
OR: [
{ status: { notIn: ["VERSENDET", "ABGEHOLT", "STORNIERT"] } },
{ returnRequest: { is: { status: "ANGEFRAGT" } } },
],
});
});
});
describe("returnArchiveWhere / returnUnarchiveWhere", () => {
it("archiviert angenommene, abgelehnte und erstattete Retouren nach einem Monat ohne Bearbeitung", () => {
expect(returnArchiveWhere(archiveCutoff(NOW))).toEqual({
archivedAt: null,
status: { in: ["ANGENOMMEN", "ABGELEHNT", "ERSTATTET"] },
updatedAt: { lte: archiveCutoff(NOW) },
});
});
it("holt eine automatisch archivierte Retoure zurueck, sobald sie wieder offen ist", () => {
expect(returnUnarchiveWhere()).toEqual({
archivedAt: { not: null },
archivedManually: false,
status: { notIn: ["ANGENOMMEN", "ABGELEHNT", "ERSTATTET"] },
});
});
});
describe("productArchiveWhere / productUnarchiveWhere", () => {
it("archiviert Produkte, die seit ueber einem Monat Bestand 0 haben und nicht bearbeitet wurden", () => {
const cutoff = archiveCutoff(NOW);
expect(productArchiveWhere(cutoff)).toEqual({
archivedAt: null,
updatedAt: { lte: cutoff },
OR: [{ inventory: { is: null } }, { inventory: { is: { quantity: { lte: 0 }, updatedAt: { lte: cutoff } } } }],
});
});
it("holt ein automatisch archiviertes Produkt zurueck, sobald wieder Bestand da ist", () => {
expect(productUnarchiveWhere()).toEqual({
archivedAt: { not: null },
archivedManually: false,
inventory: { is: { quantity: { gt: 0 } } },
});
});
});
describe("runAutoArchive", () => {
it("archiviert faellige Eintraege und holt wieder offene zurueck, jeweils nur fuer die angefragte Liste", async () => {
const db = fakeDb({ archived: 3, restored: 1 });
const result = await runAutoArchive(db as unknown as ArchiveDb, "orders", NOW);
expect(result).toEqual({ archived: 3, restored: 1 });
expect(db.order.updateMany).toHaveBeenNthCalledWith(1, {
where: orderArchiveWhere(archiveCutoff(NOW)),
data: { archivedAt: NOW, archivedManually: false },
});
expect(db.order.updateMany).toHaveBeenNthCalledWith(2, {
where: orderUnarchiveWhere(),
data: { archivedAt: null },
});
expect(db.product.updateMany).not.toHaveBeenCalled();
expect(db.returnRequest.updateMany).not.toHaveBeenCalled();
});
it("nutzt fuer Retouren und Produkte die jeweils eigenen Regeln", async () => {
const db = fakeDb({ archived: 0, restored: 0 });
await runAutoArchive(db as unknown as ArchiveDb, "returns", NOW);
await runAutoArchive(db as unknown as ArchiveDb, "products", NOW);
expect(db.returnRequest.updateMany.mock.calls[0][0].where).toEqual(returnArchiveWhere(archiveCutoff(NOW)));
expect(db.product.updateMany.mock.calls[0][0].where).toEqual(productArchiveWhere(archiveCutoff(NOW)));
});
});
+149
View File
@@ -0,0 +1,149 @@
/**
* Archiv fuer Bestellungen, Retouren und Produkte im Admin-Bereich.
*
* Abgeschlossene Eintraege wandern nach einem Monat ohne Bearbeitung in den
* Tab "Archiv", damit die aktiven Listen nur zeigen, was noch zu tun ist:
* - Bestellungen: Status VERSENDET/ABGEHOLT/STORNIERT und keine offene Retoure
* - Retouren: Status ANGENOMMEN/ABGELEHNT/ERSTATTET
* - Produkte: Bestand 0 — Inventory.updatedAt ist dabei der Zeitpunkt der
* letzten Bestandsaenderung, also der Moment, seit dem der Bestand 0 ist.
* Nur Admin-Sicht: der Shop zeigt ein archiviertes Produkt unveraendert.
*
* Die Automatik laeuft beim Oeffnen der jeweiligen Admin-Liste (kein
* CronJob) und holt automatisch archivierte Eintraege zurueck, sobald sie
* wieder offen sind (Status geaendert, neue Retoure, Bestand aufgefuellt).
* Von Hand archivierte Eintraege (archivedManually) fasst sie nicht an.
* "Wiederherstellen" per Hand setzt ueber @updatedAt die Bearbeitungszeit neu
* — der Eintrag bleibt dadurch einen weiteren Monat aktiv, statt beim
* naechsten Seitenaufruf sofort wieder zu verschwinden.
*
* Die Datenbank wird hereingereicht statt importiert, damit sich die Regeln
* ohne DATABASE_URL testen lassen (siehe archive.test.ts).
*/
import type { Prisma } from "@/generated/prisma/client";
export const ARCHIVE_AFTER_DAYS = 30;
const DAY_MS = 24 * 60 * 60 * 1000;
export const ORDER_DONE_STATUSES = ["VERSENDET", "ABGEHOLT", "STORNIERT"] as const;
export const RETURN_DONE_STATUSES = ["ANGENOMMEN", "ABGELEHNT", "ERSTATTET"] as const;
export type ArchiveKind = "orders" | "returns" | "products";
/** URL-Parameter fuer den Archiv-Tab, z.B. /admin/bestellungen?ansicht=archiv */
export const ARCHIVE_VIEW_PARAM = "ansicht";
export const ARCHIVE_VIEW_VALUE = "archiv";
export function isArchiveView(value: unknown): boolean {
return value === ARCHIVE_VIEW_VALUE;
}
export function archiveCutoff(now: Date): Date {
return new Date(now.getTime() - ARCHIVE_AFTER_DAYS * DAY_MS);
}
export function orderArchiveWhere(cutoff: Date) {
return {
archivedAt: null,
status: { in: [...ORDER_DONE_STATUSES] },
updatedAt: { lte: cutoff },
// Eine Retoure haengt in eigener Tabelle — ihre Bearbeitung aendert
// Order.updatedAt nicht und muss deshalb eigens abgewartet werden.
OR: [
{ returnRequest: { is: null } },
{ returnRequest: { is: { status: { in: [...RETURN_DONE_STATUSES] }, updatedAt: { lte: cutoff } } } },
],
} satisfies Prisma.OrderWhereInput;
}
export function orderUnarchiveWhere() {
return {
archivedAt: { not: null },
archivedManually: false,
OR: [
{ status: { notIn: [...ORDER_DONE_STATUSES] } },
{ returnRequest: { is: { status: "ANGEFRAGT" } } },
],
} satisfies Prisma.OrderWhereInput;
}
export function returnArchiveWhere(cutoff: Date) {
return {
archivedAt: null,
status: { in: [...RETURN_DONE_STATUSES] },
updatedAt: { lte: cutoff },
} satisfies Prisma.ReturnRequestWhereInput;
}
export function returnUnarchiveWhere() {
return {
archivedAt: { not: null },
archivedManually: false,
status: { notIn: [...RETURN_DONE_STATUSES] },
} satisfies Prisma.ReturnRequestWhereInput;
}
export function productArchiveWhere(cutoff: Date) {
return {
archivedAt: null,
updatedAt: { lte: cutoff },
OR: [
// Ohne Inventory-Zeile gilt der Bestand als 0 (so zeigt ihn auch die Liste).
{ inventory: { is: null } },
{ inventory: { is: { quantity: { lte: 0 }, updatedAt: { lte: cutoff } } } },
],
} satisfies Prisma.ProductWhereInput;
}
export function productUnarchiveWhere() {
return {
archivedAt: { not: null },
archivedManually: false,
inventory: { is: { quantity: { gt: 0 } } },
} satisfies Prisma.ProductWhereInput;
}
interface UpdateManyDelegate<Where> {
updateMany(args: { where: Where; data: { archivedAt: Date | null; archivedManually?: boolean } }): Promise<{
count: number;
}>;
}
/** Der Ausschnitt des Prisma-Clients, den die Automatik braucht. */
export interface ArchiveDb {
order: UpdateManyDelegate<Prisma.OrderWhereInput>;
returnRequest: UpdateManyDelegate<Prisma.ReturnRequestWhereInput>;
product: UpdateManyDelegate<Prisma.ProductWhereInput>;
}
export interface AutoArchiveResult {
archived: number;
restored: number;
}
export async function runAutoArchive(db: ArchiveDb, kind: ArchiveKind, now: Date = new Date()): Promise<AutoArchiveResult> {
const cutoff = archiveCutoff(now);
const archiveData = { archivedAt: now, archivedManually: false };
const restoreData = { archivedAt: null };
// Jeweils ein einzelnes UPDATE ... WHERE: die Bedingung wird atomar gegen
// den aktuellen Zeilenstand geprueft, ein gleichzeitiger Statuswechsel kann
// also keinen gerade wieder offenen Eintrag ins Archiv schieben.
switch (kind) {
case "orders": {
const archived = await db.order.updateMany({ where: orderArchiveWhere(cutoff), data: archiveData });
const restored = await db.order.updateMany({ where: orderUnarchiveWhere(), data: restoreData });
return { archived: archived.count, restored: restored.count };
}
case "returns": {
const archived = await db.returnRequest.updateMany({ where: returnArchiveWhere(cutoff), data: archiveData });
const restored = await db.returnRequest.updateMany({ where: returnUnarchiveWhere(), data: restoreData });
return { archived: archived.count, restored: restored.count };
}
case "products": {
const archived = await db.product.updateMany({ where: productArchiveWhere(cutoff), data: archiveData });
const restored = await db.product.updateMany({ where: productUnarchiveWhere(), data: restoreData });
return { archived: archived.count, restored: restored.count };
}
}
}