feat(admin): archive tabs for business inquiries and reviews

Inquiries: completed and rejected inquiries move to an "Archiv" tab;
resetting the status brings them back. Reviews: new "Ablehnen" action
(Review.rejectedAt) instead of only deleting; the archive shows decided
reviews in two columns, accepted and rejected, and either decision can
be reversed. Pending counters in the sidebar and dashboard exclude
rejected reviews, and the product page tells the author their review was
not published instead of "still being checked".

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-02 04:13:07 +02:00
1 parent 866d3726d1
commit f1c0da4673
16 files changed
+460 -114

No files matched your search

+3 -2
View File
@@ -5,16 +5,17 @@ import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log";
import { requireAdmin, str, fail } from "./_shared";
import { setFlash } from "@/lib/flash";
import { isInquiryStatus } from "@/lib/inquiry-status";
export async function updateBusinessInquiryStatusAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const id = str(formData, "id");
const status = str(formData, "status");
if (!["NEU", "IN_BEARBEITUNG", "ABGESCHLOSSEN", "ABGELEHNT"].includes(status)) return fail("/admin/anfragen", "Ungültiger Status.");
if (!isInquiryStatus(status)) return fail("/admin/anfragen", "Ungültiger Status.");
await prisma.businessInquiry.update({
where: { id },
data: { status: status as "NEU" | "IN_BEARBEITUNG" | "ABGESCHLOSSEN" | "ABGELEHNT" },
data: { status },
});
await logAdminAction(admin, {
+28 -1
View File
@@ -12,7 +12,7 @@ export async function approveReviewAction(formData: FormData): Promise<void> {
const review = await prisma.review.update({
where: { id },
data: { isApproved: true },
data: { isApproved: true, rejectedAt: null },
select: { product: { select: { slug: true, name: true } } },
});
@@ -28,6 +28,33 @@ export async function approveReviewAction(formData: FormData): Promise<void> {
await setFlash("success", "Bewertung freigegeben — sie ist jetzt im Shop sichtbar.");
}
/**
* Lehnt eine Bewertung ab, statt sie zu loeschen: sie verschwindet aus dem
* Shop (falls sie dort war) und steht im Archiv unter "Abgelehnt", damit die
* Entscheidung nachvollziehbar bleibt und sich rueckgaengig machen laesst.
*/
export async function rejectReviewAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const id = str(formData, "id");
const review = await prisma.review.update({
where: { id },
data: { isApproved: false, rejectedAt: new Date() },
select: { product: { select: { slug: true, name: true } } },
});
await logAdminAction(admin, {
action: "review.reject",
entityType: "Review",
entityId: id,
detail: review.product.name,
});
revalidatePath("/admin/bewertungen");
revalidatePath(`/honig/${review.product.slug}`);
await setFlash("success", "Bewertung abgelehnt — sie steht jetzt im Archiv.");
}
export async function deleteReviewAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const id = str(formData, "id");
+49 -16
View File
@@ -9,6 +9,9 @@ import {
AdminSearchForm,
type AdminBadgeTone,
} from "@/components/admin-list";
import { AdminArchiveTabs } from "@/components/admin-archive";
import { isArchiveView } from "@/lib/archive";
import { inquiryViewWhere } from "@/lib/inquiry-status";
import { updateBusinessInquiryStatusAction } from "../actions";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
@@ -28,35 +31,57 @@ export default async function AdminInquiriesPage(props: PageProps<"/admin/anfrag
ABGESCHLOSSEN: t.adminInquiriesPage.statusCompleted,
ABGELEHNT: t.adminInquiriesPage.statusRejected,
};
const { q } = await props.searchParams;
const { q, ansicht } = await props.searchParams;
const query = typeof q === "string" ? q.trim() : "";
const isArchive = isArchiveView(ansicht);
const inquiries = await prisma.businessInquiry.findMany({
where: query
? {
OR: [
{ companyName: insensitiveContains(query) },
{ contactPerson: insensitiveContains(query) },
{ email: insensitiveContains(query) },
],
}
: undefined,
orderBy: { createdAt: "desc" },
take: 100,
});
const filterWhere = query
? {
OR: [
{ companyName: insensitiveContains(query) },
{ contactPerson: insensitiveContains(query) },
{ email: insensitiveContains(query) },
],
}
: {};
const [inquiries, activeCount, archivedCount] = await Promise.all([
prisma.businessInquiry.findMany({
where: { ...filterWhere, ...inquiryViewWhere(isArchive) },
// Aktiv: zuletzt eingegangen oben. Archiv: zuletzt erledigt oben.
orderBy: isArchive ? { updatedAt: "desc" } : { createdAt: "desc" },
take: 100,
}),
prisma.businessInquiry.count({ where: { ...filterWhere, ...inquiryViewWhere(false) } }),
prisma.businessInquiry.count({ where: { ...filterWhere, ...inquiryViewWhere(true) } }),
]);
return (
<div>
<BackLink href="/admin" label={t.adminInquiriesPage.backToDashboard} />
<AdminPageHeader title={t.adminInquiriesPage.title} />
<AdminArchiveTabs
basePath="/admin/anfragen"
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.autoRuleInquiries}</p> : null}
<AdminSearchForm
query={query}
label={t.adminInquiriesPage.searchLabel}
placeholder={t.adminInquiriesPage.searchPlaceholder}
submitLabel={t.adminInquiriesPage.filterButton}
resetHref="/admin/anfragen"
resetHref={isArchive ? "/admin/anfragen?ansicht=archiv" : "/admin/anfragen"}
resetLabel={t.adminInquiriesPage.resetFilter}
hiddenFields={isArchive ? { ansicht: "archiv" } : {}}
/>
<div className="space-y-4">
{inquiries.map((inquiry) => (
@@ -119,7 +144,15 @@ export default async function AdminInquiriesPage(props: PageProps<"/admin/anfrag
{inquiries.length === 0 ? (
<AdminEmptyState
bordered
message={query ? t.adminInquiriesPage.noResultsFiltered : t.adminInquiriesPage.noInquiriesYet}
message={
query
? t.adminInquiriesPage.noResultsFiltered
: isArchive
? t.adminArchive.emptyArchive
: archivedCount > 0
? t.adminInquiriesPage.noOpenInquiries
: t.adminInquiriesPage.noInquiriesYet
}
/>
) : null}
</div>
+213 -92
View File
@@ -1,5 +1,6 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma";
import type { Prisma } from "@/generated/prisma/client";
import { BackLink } from "@/components/back-link";
import { ConfirmForm } from "@/components/confirm-form";
import { formatDate } from "@/lib/format";
@@ -12,114 +13,234 @@ import {
AdminRowActions,
AdminSearchForm,
} from "@/components/admin-list";
import { AdminArchiveTabs } from "@/components/admin-archive";
import { insensitiveContains } from "@/lib/prisma-search";
import { approveReviewAction, deleteReviewAction } from "../actions";
import { isArchiveView } from "@/lib/archive";
import { approvedReviewWhere, pendingReviewWhere, rejectedReviewWhere } from "@/lib/review-status";
import { approveReviewAction, deleteReviewAction, rejectReviewAction } from "../actions";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
import type { Dictionary } from "@/i18n/dictionary-type";
const MAX_ROWS = 100;
const BASE_PATH = "/admin/bewertungen";
const REVIEW_INCLUDE = {
product: { select: { name: true, slug: true } },
customer: { select: { firstName: true, lastName: true } },
} satisfies Prisma.ReviewInclude;
type AdminReview = Prisma.ReviewGetPayload<{ include: typeof REVIEW_INCLUDE }>;
type ReviewTexts = Dictionary["adminReviewsPage"];
function searchWhere(query: string): Prisma.ReviewWhereInput {
if (!query) return {};
return {
OR: [
{ product: { name: insensitiveContains(query) } },
{ customer: { firstName: insensitiveContains(query) } },
{ customer: { lastName: insensitiveContains(query) } },
],
};
}
export default async function AdminBewertungenPage(props: PageProps<"/admin/bewertungen">) {
const t = getDictionary(await getLocale());
const { q } = await props.searchParams;
const s = t.adminReviewsPage;
const { q, ansicht } = await props.searchParams;
const query = typeof q === "string" ? q.trim() : "";
const isArchive = isArchiveView(ansicht);
const filter = searchWhere(query);
const reviews = await prisma.review.findMany({
where: query
? {
OR: [
{ product: { name: insensitiveContains(query) } },
{ customer: { firstName: insensitiveContains(query) } },
{ customer: { lastName: insensitiveContains(query) } },
],
}
: undefined,
include: {
product: { select: { name: true, slug: true } },
customer: { select: { firstName: true, lastName: true } },
},
orderBy: [{ isApproved: "asc" }, { createdAt: "desc" }],
take: 100,
});
const findReviews = (where: Prisma.ReviewWhereInput, orderBy: Prisma.ReviewOrderByWithRelationInput) =>
prisma.review.findMany({ where: { ...filter, ...where }, include: REVIEW_INCLUDE, orderBy, take: MAX_ROWS });
// Offene: aelteste zuerst, sie warten am laengsten. Archiv: zuletzt entschiedene oben.
const [pending, approved, rejected, pendingCount, decidedCount] = await Promise.all([
isArchive ? Promise.resolve([]) : findReviews(pendingReviewWhere(), { createdAt: "asc" }),
isArchive ? findReviews(approvedReviewWhere(), { updatedAt: "desc" }) : Promise.resolve([]),
isArchive ? findReviews(rejectedReviewWhere(), { rejectedAt: "desc" }) : Promise.resolve([]),
prisma.review.count({ where: { ...filter, ...pendingReviewWhere() } }),
prisma.review.count({ where: { ...filter, NOT: pendingReviewWhere() } }),
]);
return (
<div>
<BackLink href="/admin" label={t.adminReviewsPage.backToDashboard} />
<AdminPageHeader title={t.adminReviewsPage.title} />
<BackLink href="/admin" label={s.backToDashboard} />
<AdminPageHeader title={s.title} />
<AdminArchiveTabs
basePath={BASE_PATH}
isArchive={isArchive}
counts={{ active: pendingCount, archived: decidedCount }}
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.autoRuleReviews}</p> : null}
<AdminSearchForm
query={query}
label={t.adminReviewsPage.searchLabel}
placeholder={t.adminReviewsPage.searchPlaceholder}
submitLabel={t.adminReviewsPage.filterButton}
resetHref="/admin/bewertungen"
resetLabel={t.adminReviewsPage.resetFilter}
label={s.searchLabel}
placeholder={s.searchPlaceholder}
submitLabel={s.filterButton}
resetHref={isArchive ? `${BASE_PATH}?ansicht=archiv` : BASE_PATH}
resetLabel={s.resetFilter}
hiddenFields={isArchive ? { ansicht: "archiv" } : {}}
/>
<div className="overflow-x-auto bg-background border border-ink/10">
<table className="w-full text-sm">
<thead className="text-left text-ink/65 border-b border-ink/10">
<tr>
<th className="p-4">{t.adminReviewsPage.colProduct}</th>
<th className="p-4">{t.adminReviewsPage.colCustomer}</th>
<th className="p-4">{t.adminReviewsPage.colRating}</th>
<th className="p-4">{t.adminReviewsPage.colComment}</th>
<th className="p-4">{t.adminReviewsPage.colDate}</th>
<th className="p-4">{t.adminReviewsPage.colStatus}</th>
<th className="p-4" />
</tr>
</thead>
<tbody className="divide-y divide-ink/10">
{reviews.map((review) => (
<tr key={review.id} className="transition-colors hover:bg-ink/[0.03]">
<td className="p-4">
<Link href={`/honig/${review.product.slug}`} className="link-draw text-heading">
{review.product.name}
</Link>
</td>
<td className="p-4">
{review.customer.firstName} {review.customer.lastName}
</td>
<td className="p-4 whitespace-nowrap">
<StarRating value={review.rating} />
<span className="sr-only">{review.rating} / 5</span>
</td>
<td className="p-4 max-w-xs">{review.comment ?? "—"}</td>
<td className="p-4 whitespace-nowrap">{formatDate(review.createdAt)}</td>
<td className="p-4">
{review.isApproved ? (
<AdminBadge tone="positive">{t.adminReviewsPage.statusPublished}</AdminBadge>
) : (
<AdminBadge tone="pending" pulse>
{t.adminReviewsPage.statusPending}
</AdminBadge>
)}
</td>
<td className="p-4">
<AdminRowActions>
{!review.isApproved ? (
<form action={approveReviewAction}>
<input type="hidden" name="id" value={review.id} />
<AdminActionButton>{t.adminReviewsPage.approve}</AdminActionButton>
</form>
) : null}
<ConfirmForm
action={deleteReviewAction}
confirmMessage={t.adminReviewsPage.deleteConfirm}
>
<input type="hidden" name="id" value={review.id} />
<AdminActionButton tone="danger">{t.adminReviewsPage.delete}</AdminActionButton>
</ConfirmForm>
</AdminRowActions>
</td>
</tr>
))}
</tbody>
</table>
{reviews.length === 0 ? (
<AdminEmptyState
message={query ? t.adminReviewsPage.noResultsFiltered : t.adminReviewsPage.noReviewsYet}
/>
) : null}
</div>
{isArchive ? (
<div className="grid gap-6 lg:grid-cols-2">
<DecidedColumn title={s.columnApproved} reviews={approved} decision="approved" s={s} />
<DecidedColumn title={s.columnRejected} reviews={rejected} decision="rejected" s={s} />
</div>
) : (
<PendingTable reviews={pending} query={query} s={s} />
)}
</div>
);
}
function PendingTable({ reviews, query, s }: { reviews: AdminReview[]; query: string; s: ReviewTexts }) {
return (
<div className="overflow-x-auto bg-background border border-ink/10">
<table className="w-full text-sm">
<thead className="text-left text-ink/65 border-b border-ink/10">
<tr>
<th className="p-4">{s.colProduct}</th>
<th className="p-4">{s.colCustomer}</th>
<th className="p-4">{s.colRating}</th>
<th className="p-4">{s.colComment}</th>
<th className="p-4">{s.colDate}</th>
<th className="p-4">{s.colStatus}</th>
<th className="p-4" />
</tr>
</thead>
<tbody className="divide-y divide-ink/10">
{reviews.map((review) => (
<tr key={review.id} className="transition-colors hover:bg-ink/[0.03]">
<td className="p-4">
<ProductLink review={review} />
</td>
<td className="p-4">
{review.customer.firstName} {review.customer.lastName}
</td>
<td className="p-4 whitespace-nowrap">
<Stars rating={review.rating} />
</td>
<td className="p-4 max-w-xs">{review.comment ?? "—"}</td>
<td className="p-4 whitespace-nowrap">{formatDate(review.createdAt)}</td>
<td className="p-4">
<AdminBadge tone="pending" pulse>
{s.statusPending}
</AdminBadge>
</td>
<td className="p-4">
<ReviewActions review={review} decision="pending" s={s} />
</td>
</tr>
))}
</tbody>
</table>
{reviews.length === 0 ? (
<AdminEmptyState message={query ? s.noResultsFiltered : s.noPendingReviews} />
) : null}
</div>
);
}
function DecidedColumn({
title,
reviews,
decision,
s,
}: {
title: string;
reviews: AdminReview[];
decision: "approved" | "rejected";
s: ReviewTexts;
}) {
return (
<section aria-label={title} className="bg-background border border-ink/10">
<h2 className="flex items-center justify-between gap-3 border-b border-ink/10 px-5 py-3 text-heading">
{title}
<AdminBadge tone={decision === "approved" ? "positive" : "neutral"}>{reviews.length}</AdminBadge>
</h2>
{reviews.length === 0 ? (
<p className="px-5 py-6 text-sm text-ink/60">{s.emptyColumn}</p>
) : (
<ul className="divide-y divide-ink/10">
{reviews.map((review) => (
<li key={review.id} className="space-y-2 px-5 py-4 text-sm">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<ProductLink review={review} />
<Stars rating={review.rating} />
</div>
<p className="text-ink/70">
{review.customer.firstName} {review.customer.lastName} ·{" "}
<span className="tabular-nums">{formatDate(review.createdAt)}</span>
</p>
{review.comment ? <p className="text-ink/80">{review.comment}</p> : null}
{decision === "rejected" && review.rejectedAt ? (
<p className="text-xs text-ink/55">{s.rejectedOn(formatDate(review.rejectedAt))}</p>
) : null}
<ReviewActions review={review} decision={decision} s={s} />
</li>
))}
</ul>
)}
</section>
);
}
function ProductLink({ review }: { review: AdminReview }) {
return (
<Link href={`/honig/${review.product.slug}`} className="link-draw text-heading">
{review.product.name}
</Link>
);
}
function Stars({ rating }: { rating: number }) {
return (
<span className="whitespace-nowrap">
<StarRating value={rating} />
<span className="sr-only">{rating} / 5</span>
</span>
);
}
/** Freischalten und Ablehnen jeweils nur dort, wo es den Stand aendert; Loeschen immer. */
function ReviewActions({
review,
decision,
s,
}: {
review: AdminReview;
decision: "pending" | "approved" | "rejected";
s: ReviewTexts;
}) {
return (
<AdminRowActions>
{decision !== "approved" ? (
<form action={approveReviewAction}>
<input type="hidden" name="id" value={review.id} />
<AdminActionButton>{s.approve}</AdminActionButton>
</form>
) : null}
{decision !== "rejected" ? (
<ConfirmForm action={rejectReviewAction} confirmMessage={s.rejectConfirm}>
<input type="hidden" name="id" value={review.id} />
<AdminActionButton>{s.reject}</AdminActionButton>
</ConfirmForm>
) : null}
<ConfirmForm action={deleteReviewAction} confirmMessage={s.deleteConfirm}>
<input type="hidden" name="id" value={review.id} />
<AdminActionButton tone="danger">{s.delete}</AdminActionButton>
</ConfirmForm>
</AdminRowActions>
);
}
+2 -1
View File
@@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
import { noticeUrl } from "@/lib/notice";
import { getCurrentAdmin } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { pendingReviewWhere } from "@/lib/review-status";
import { logoutAction } from "@/app/konto/actions";
import { AdminShell } from "@/components/admin-shell";
import { getLocale } from "@/i18n/locale";
@@ -58,7 +59,7 @@ export default async function AdminLayout({ children }: LayoutProps<"/admin">) {
}),
prisma.returnRequest.count({ where: { status: "ANGEFRAGT" } }),
prisma.withdrawalDeclaration.count({ where: { processedAt: null } }),
prisma.review.count({ where: { isApproved: false } }),
prisma.review.count({ where: pendingReviewWhere() }),
prisma.businessInquiry.count({ where: { status: "NEU" } }),
]);
+2 -1
View File
@@ -1,6 +1,7 @@
import Link from "next/link";
import { ApiCoreSyncPanel, ApiCoreSyncResult } from "@/components/apicore-sync-panel";
import { prisma } from "@/lib/prisma";
import { pendingReviewWhere } from "@/lib/review-status";
import { COUNTED_SALE_WHERE } from "@/lib/sales";
import { formatPrice } from "@/lib/format";
import { getLocale } from "@/i18n/locale";
@@ -264,7 +265,7 @@ export default async function AdminDashboardPage(props: PageProps<"/admin">) {
where: { status: { in: ["BEZAHLT", "IN_VORBEREITUNG", "VERSANDBEREIT", "ABHOLBEREIT"] }, archivedAt: null },
}),
prisma.returnRequest.count({ where: { status: "ANGEFRAGT" } }),
prisma.review.count({ where: { isApproved: false } }),
prisma.review.count({ where: pendingReviewWhere() }),
prisma.businessInquiry.count({ where: { status: "NEU" } }),
]);
const toDo = [
+1
View File
@@ -76,6 +76,7 @@ export async function GET() {
comment: r.comment,
createdAt: r.createdAt,
isApproved: r.isApproved,
rejectedAt: r.rejectedAt,
})),
wishlist: wishlistItems.map((w) => ({ product: w.product.name, addedAt: w.createdAt })),
newsletter: newsletter
+5 -1
View File
@@ -482,7 +482,11 @@ export default async function ProductDetailPage(
</p>
) : ownReview ? (
<p className="text-sm text-ink/70">
{!ownReview.isApproved ? t.productPage.alreadyReviewedPending : t.productPage.alreadyReviewed}
{ownReview.isApproved
? t.productPage.alreadyReviewed
: ownReview.rejectedAt
? t.productPage.alreadyReviewedRejected
: t.productPage.alreadyReviewedPending}
</p>
) : !canReview ? (
<p className="text-sm text-ink/70">{t.productPage.onlyBuyersCanReview}</p>