feat: Archiv-Tab fuer Bestellungen, Retouren und Produkte im Admin
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 3m24s
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:
1 parent
01eaca39dc
commit
fb9c2353c6
14 files changed
+735
-17
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");
|
||||
@@ -255,6 +255,13 @@ model Product {
|
||||
/// Liste von Bild-URLs als JSON-Array, siehe tasteTags-Kommentar oben.
|
||||
images Json @default("[]")
|
||||
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())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -266,6 +273,7 @@ model Product {
|
||||
wishlistItems WishlistItem[]
|
||||
stockNotifications StockNotification[]
|
||||
|
||||
@@index([archivedAt])
|
||||
@@map("products")
|
||||
}
|
||||
|
||||
@@ -412,6 +420,14 @@ model Order {
|
||||
/// Verpacken von Hand beigelegt (siehe Admin-Bestelldetail).
|
||||
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())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -434,6 +450,8 @@ model Order {
|
||||
@@index([createdAt])
|
||||
/// Nutzungszaehlung eines Rabattcodes beim Checkout (Order.count nach discountCodeId).
|
||||
@@index([discountCodeId])
|
||||
/// Tab-Filter Aktiv/Archiv im Admin-Bereich.
|
||||
@@index([archivedAt])
|
||||
@@map("orders")
|
||||
}
|
||||
|
||||
@@ -451,11 +469,19 @@ model ReturnRequest {
|
||||
orderId String @unique
|
||||
reason String
|
||||
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())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([archivedAt])
|
||||
@@map("return_requests")
|
||||
}
|
||||
|
||||
|
||||
@@ -14,3 +14,4 @@ export * from "./actions/discount-codes";
|
||||
export * from "./actions/content";
|
||||
export * from "./actions/apicore-sync";
|
||||
export * from "./actions/dhl";
|
||||
export * from "./actions/archive";
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -8,16 +8,21 @@ 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 } = await props.searchParams;
|
||||
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);
|
||||
|
||||
const orders = await prisma.order.findMany({
|
||||
where: {
|
||||
await ensureAutoArchived("orders");
|
||||
|
||||
const filterWhere = {
|
||||
...(statusFilter ? { status: statusFilter } : {}),
|
||||
...(query
|
||||
? {
|
||||
@@ -28,11 +33,18 @@ export default async function AdminOrdersPage(props: PageProps<"/admin/bestellun
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
|
||||
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>
|
||||
@@ -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>
|
||||
) : 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
|
||||
@@ -78,7 +104,10 @@ export default async function AdminOrdersPage(props: PageProps<"/admin/bestellun
|
||||
{t.adminOrdersPage.filterButton}
|
||||
</button>
|
||||
{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}
|
||||
</Link>
|
||||
) : 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.colStatus}</th>
|
||||
<th className="p-4">{t.adminOrdersPage.colFulfillment}</th>
|
||||
<th className="p-4" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink/10">
|
||||
@@ -141,13 +171,31 @@ export default async function AdminOrdersPage(props: PageProps<"/admin/bestellun
|
||||
<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 : t.adminOrdersPage.noOrdersYet}
|
||||
{query || statusFilter
|
||||
? t.adminOrdersPage.noResultsFiltered
|
||||
: isArchive
|
||||
? t.adminArchive.emptyArchive
|
||||
: t.adminOrdersPage.noOrdersYet}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,20 @@ import { deleteProductAction } from "../actions";
|
||||
import { ApiCoreSyncPanel, ApiCoreSyncResult } from "@/components/apicore-sync-panel";
|
||||
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 AdminProductsPage(props: PageProps<"/admin/produkte">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const searchParams = await props.searchParams;
|
||||
const products = await prisma.product.findMany({
|
||||
const isArchive = isArchiveView(searchParams.ansicht);
|
||||
|
||||
await ensureAutoArchived("products");
|
||||
|
||||
const [products, activeCount, archivedCount] = await Promise.all([
|
||||
prisma.product.findMany({
|
||||
where: { archivedAt: isArchive ? { not: null } : null },
|
||||
include: {
|
||||
harvest: true,
|
||||
batch: true,
|
||||
@@ -20,7 +29,10 @@ export default async function AdminProductsPage(props: PageProps<"/admin/produkt
|
||||
_count: { select: { orderItems: true } },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
}),
|
||||
prisma.product.count({ where: { archivedAt: null } }),
|
||||
prisma.product.count({ where: { archivedAt: { not: null } } }),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -34,6 +46,19 @@ export default async function AdminProductsPage(props: PageProps<"/admin/produkt
|
||||
|
||||
<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">
|
||||
<table className="w-full text-sm">
|
||||
<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">
|
||||
{t.adminProductsPage.edit}
|
||||
</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
|
||||
onDelete: Restrict auf das Produkt, damit gestellte
|
||||
Rechnungen ihren Bezug behalten (siehe
|
||||
@@ -100,7 +139,9 @@ export default async function AdminProductsPage(props: PageProps<"/admin/produkt
|
||||
</tbody>
|
||||
</table>
|
||||
{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}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@ import { insensitiveContains } from "@/lib/prisma-search";
|
||||
import { updateReturnRequestStatusAction, refundReturnRequestAction } from "./actions";
|
||||
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 AdminRetourenPage(props: PageProps<"/admin/retouren">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
@@ -15,11 +18,13 @@ export default async function AdminRetourenPage(props: PageProps<"/admin/retoure
|
||||
ABGELEHNT: t.adminReturnsPage.statusRejected,
|
||||
ERSTATTET: t.adminReturnsPage.statusRefunded,
|
||||
};
|
||||
const { q } = await props.searchParams;
|
||||
const { q, ansicht } = await props.searchParams;
|
||||
const query = typeof q === "string" ? q.trim() : "";
|
||||
const isArchive = isArchiveView(ansicht);
|
||||
|
||||
const returnRequests = await prisma.returnRequest.findMany({
|
||||
where: query
|
||||
await ensureAutoArchived("returns");
|
||||
|
||||
const filterWhere = query
|
||||
? {
|
||||
order: {
|
||||
OR: [
|
||||
@@ -30,20 +35,41 @@ export default async function AdminRetourenPage(props: PageProps<"/admin/retoure
|
||||
],
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
: {};
|
||||
|
||||
const [returnRequests, activeCount, archivedCount] = await Promise.all([
|
||||
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 (
|
||||
<div>
|
||||
<BackLink href="/admin" label={t.adminReturnsPage.backToDashboard} />
|
||||
<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">
|
||||
{isArchive ? <input type="hidden" name="ansicht" value="archiv" /> : null}
|
||||
<label className="text-sm">
|
||||
<span className="block text-ink/65 text-xs mb-1">{t.adminReturnsPage.searchLabel}</span>
|
||||
<input
|
||||
@@ -58,7 +84,10 @@ export default async function AdminRetourenPage(props: PageProps<"/admin/retoure
|
||||
{t.adminReturnsPage.filterButton}
|
||||
</button>
|
||||
{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}
|
||||
</Link>
|
||||
) : null}
|
||||
@@ -75,7 +104,23 @@ export default async function AdminRetourenPage(props: PageProps<"/admin/retoure
|
||||
</p>
|
||||
<p className="text-xs text-ink/65 mt-1">{formatDate(request.createdAt)}</p>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<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 ? (
|
||||
<p className="text-ink/65">
|
||||
{query ? t.adminReturnsPage.noResultsFiltered : t.adminReturnsPage.noRequestsYet}
|
||||
{query
|
||||
? t.adminReturnsPage.noResultsFiltered
|
||||
: isArchive
|
||||
? t.adminArchive.emptyArchive
|
||||
: t.adminReturnsPage.noRequestsYet}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -927,6 +927,22 @@ export const de: Dictionary = {
|
||||
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: {
|
||||
eyebrow: "Fehler",
|
||||
title: "Etwas ist schiefgelaufen.",
|
||||
|
||||
@@ -915,6 +915,21 @@ export const en: Dictionary = {
|
||||
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: {
|
||||
eyebrow: "Error",
|
||||
title: "Something went wrong.",
|
||||
|
||||
@@ -603,6 +603,19 @@ export interface Dictionary {
|
||||
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: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user