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
+772
-54
No files matched your search
@@ -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,31 +8,43 @@ 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: {
|
||||
...(statusFilter ? { status: statusFilter } : {}),
|
||||
...(query
|
||||
? {
|
||||
OR: [
|
||||
{ orderNumber: insensitiveContains(query) },
|
||||
{ email: insensitiveContains(query) },
|
||||
{ lastName: insensitiveContains(query) },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 100,
|
||||
include: { items: true },
|
||||
});
|
||||
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>
|
||||
@@ -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,20 +7,32 @@ 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({
|
||||
include: {
|
||||
harvest: true,
|
||||
batch: true,
|
||||
inventory: true,
|
||||
// Entscheidet, ob geloescht werden darf — siehe Loeschknopf unten.
|
||||
_count: { select: { orderItems: true } },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
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,
|
||||
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 (
|
||||
<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,35 +18,58 @@ 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
|
||||
? {
|
||||
order: {
|
||||
OR: [
|
||||
{ orderNumber: insensitiveContains(query) },
|
||||
{ firstName: insensitiveContains(query) },
|
||||
{ lastName: insensitiveContains(query) },
|
||||
{ email: insensitiveContains(query) },
|
||||
],
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 100,
|
||||
include: {
|
||||
order: { include: { items: true } },
|
||||
},
|
||||
});
|
||||
await ensureAutoArchived("returns");
|
||||
|
||||
const filterWhere = query
|
||||
? {
|
||||
order: {
|
||||
OR: [
|
||||
{ orderNumber: insensitiveContains(query) },
|
||||
{ firstName: insensitiveContains(query) },
|
||||
{ lastName: insensitiveContains(query) },
|
||||
{ email: insensitiveContains(query) },
|
||||
],
|
||||
},
|
||||
}
|
||||
: {};
|
||||
|
||||
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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
Reference in new issue
Block a user