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:
1 parent
866d3726d1
commit
f1c0da4673
16 files changed
+460
-114
No files matched your search
@@ -0,0 +1,5 @@
|
|||||||
|
-- Abgelehnte Bewertungen werden nicht mehr geloescht, sondern markiert und
|
||||||
|
-- im Archiv unter "Abgelehnt" gefuehrt. null = nicht abgelehnt.
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "reviews" ADD COLUMN "rejectedAt" TIMESTAMP(3);
|
||||||
@@ -326,6 +326,10 @@ model Review {
|
|||||||
/// Nur freigeschaltete Bewertungen werden auf der Produktseite angezeigt —
|
/// Nur freigeschaltete Bewertungen werden auf der Produktseite angezeigt —
|
||||||
/// einfache Missbrauchskontrolle durch den Admin vor Veroeffentlichung.
|
/// einfache Missbrauchskontrolle durch den Admin vor Veroeffentlichung.
|
||||||
isApproved Boolean @default(false)
|
isApproved Boolean @default(false)
|
||||||
|
/// Vom Admin abgelehnt: bleibt im Archiv nachvollziehbar statt geloescht zu
|
||||||
|
/// werden. Offen ist eine Bewertung, solange weder isApproved noch
|
||||||
|
/// rejectedAt gesetzt ist (siehe lib/review-status.ts).
|
||||||
|
rejectedAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|||||||
@@ -5,16 +5,17 @@ import { prisma } from "@/lib/prisma";
|
|||||||
import { logAdminAction } from "@/lib/audit-log";
|
import { logAdminAction } from "@/lib/audit-log";
|
||||||
import { requireAdmin, str, fail } from "./_shared";
|
import { requireAdmin, str, fail } from "./_shared";
|
||||||
import { setFlash } from "@/lib/flash";
|
import { setFlash } from "@/lib/flash";
|
||||||
|
import { isInquiryStatus } from "@/lib/inquiry-status";
|
||||||
|
|
||||||
export async function updateBusinessInquiryStatusAction(formData: FormData): Promise<void> {
|
export async function updateBusinessInquiryStatusAction(formData: FormData): Promise<void> {
|
||||||
const admin = await requireAdmin();
|
const admin = await requireAdmin();
|
||||||
const id = str(formData, "id");
|
const id = str(formData, "id");
|
||||||
const status = str(formData, "status");
|
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({
|
await prisma.businessInquiry.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { status: status as "NEU" | "IN_BEARBEITUNG" | "ABGESCHLOSSEN" | "ABGELEHNT" },
|
data: { status },
|
||||||
});
|
});
|
||||||
|
|
||||||
await logAdminAction(admin, {
|
await logAdminAction(admin, {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export async function approveReviewAction(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
const review = await prisma.review.update({
|
const review = await prisma.review.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { isApproved: true },
|
data: { isApproved: true, rejectedAt: null },
|
||||||
select: { product: { select: { slug: true, name: true } } },
|
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.");
|
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> {
|
export async function deleteReviewAction(formData: FormData): Promise<void> {
|
||||||
const admin = await requireAdmin();
|
const admin = await requireAdmin();
|
||||||
const id = str(formData, "id");
|
const id = str(formData, "id");
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import {
|
|||||||
AdminSearchForm,
|
AdminSearchForm,
|
||||||
type AdminBadgeTone,
|
type AdminBadgeTone,
|
||||||
} from "@/components/admin-list";
|
} 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 { updateBusinessInquiryStatusAction } from "../actions";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
@@ -28,35 +31,57 @@ export default async function AdminInquiriesPage(props: PageProps<"/admin/anfrag
|
|||||||
ABGESCHLOSSEN: t.adminInquiriesPage.statusCompleted,
|
ABGESCHLOSSEN: t.adminInquiriesPage.statusCompleted,
|
||||||
ABGELEHNT: t.adminInquiriesPage.statusRejected,
|
ABGELEHNT: t.adminInquiriesPage.statusRejected,
|
||||||
};
|
};
|
||||||
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 inquiries = await prisma.businessInquiry.findMany({
|
const filterWhere = query
|
||||||
where: query
|
? {
|
||||||
? {
|
OR: [
|
||||||
OR: [
|
{ companyName: insensitiveContains(query) },
|
||||||
{ companyName: insensitiveContains(query) },
|
{ contactPerson: insensitiveContains(query) },
|
||||||
{ contactPerson: insensitiveContains(query) },
|
{ email: insensitiveContains(query) },
|
||||||
{ email: insensitiveContains(query) },
|
],
|
||||||
],
|
}
|
||||||
}
|
: {};
|
||||||
: undefined,
|
|
||||||
orderBy: { createdAt: "desc" },
|
const [inquiries, activeCount, archivedCount] = await Promise.all([
|
||||||
take: 100,
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<BackLink href="/admin" label={t.adminInquiriesPage.backToDashboard} />
|
<BackLink href="/admin" label={t.adminInquiriesPage.backToDashboard} />
|
||||||
<AdminPageHeader title={t.adminInquiriesPage.title} />
|
<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
|
<AdminSearchForm
|
||||||
query={query}
|
query={query}
|
||||||
label={t.adminInquiriesPage.searchLabel}
|
label={t.adminInquiriesPage.searchLabel}
|
||||||
placeholder={t.adminInquiriesPage.searchPlaceholder}
|
placeholder={t.adminInquiriesPage.searchPlaceholder}
|
||||||
submitLabel={t.adminInquiriesPage.filterButton}
|
submitLabel={t.adminInquiriesPage.filterButton}
|
||||||
resetHref="/admin/anfragen"
|
resetHref={isArchive ? "/admin/anfragen?ansicht=archiv" : "/admin/anfragen"}
|
||||||
resetLabel={t.adminInquiriesPage.resetFilter}
|
resetLabel={t.adminInquiriesPage.resetFilter}
|
||||||
|
hiddenFields={isArchive ? { ansicht: "archiv" } : {}}
|
||||||
/>
|
/>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{inquiries.map((inquiry) => (
|
{inquiries.map((inquiry) => (
|
||||||
@@ -119,7 +144,15 @@ export default async function AdminInquiriesPage(props: PageProps<"/admin/anfrag
|
|||||||
{inquiries.length === 0 ? (
|
{inquiries.length === 0 ? (
|
||||||
<AdminEmptyState
|
<AdminEmptyState
|
||||||
bordered
|
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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import type { Prisma } from "@/generated/prisma/client";
|
||||||
import { BackLink } from "@/components/back-link";
|
import { BackLink } from "@/components/back-link";
|
||||||
import { ConfirmForm } from "@/components/confirm-form";
|
import { ConfirmForm } from "@/components/confirm-form";
|
||||||
import { formatDate } from "@/lib/format";
|
import { formatDate } from "@/lib/format";
|
||||||
@@ -12,114 +13,234 @@ import {
|
|||||||
AdminRowActions,
|
AdminRowActions,
|
||||||
AdminSearchForm,
|
AdminSearchForm,
|
||||||
} from "@/components/admin-list";
|
} from "@/components/admin-list";
|
||||||
|
import { AdminArchiveTabs } from "@/components/admin-archive";
|
||||||
import { insensitiveContains } from "@/lib/prisma-search";
|
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 { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
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">) {
|
export default async function AdminBewertungenPage(props: PageProps<"/admin/bewertungen">) {
|
||||||
const t = getDictionary(await getLocale());
|
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 query = typeof q === "string" ? q.trim() : "";
|
||||||
|
const isArchive = isArchiveView(ansicht);
|
||||||
|
const filter = searchWhere(query);
|
||||||
|
|
||||||
const reviews = await prisma.review.findMany({
|
const findReviews = (where: Prisma.ReviewWhereInput, orderBy: Prisma.ReviewOrderByWithRelationInput) =>
|
||||||
where: query
|
prisma.review.findMany({ where: { ...filter, ...where }, include: REVIEW_INCLUDE, orderBy, take: MAX_ROWS });
|
||||||
? {
|
|
||||||
OR: [
|
// Offene: aelteste zuerst, sie warten am laengsten. Archiv: zuletzt entschiedene oben.
|
||||||
{ product: { name: insensitiveContains(query) } },
|
const [pending, approved, rejected, pendingCount, decidedCount] = await Promise.all([
|
||||||
{ customer: { firstName: insensitiveContains(query) } },
|
isArchive ? Promise.resolve([]) : findReviews(pendingReviewWhere(), { createdAt: "asc" }),
|
||||||
{ customer: { lastName: insensitiveContains(query) } },
|
isArchive ? findReviews(approvedReviewWhere(), { updatedAt: "desc" }) : Promise.resolve([]),
|
||||||
],
|
isArchive ? findReviews(rejectedReviewWhere(), { rejectedAt: "desc" }) : Promise.resolve([]),
|
||||||
}
|
prisma.review.count({ where: { ...filter, ...pendingReviewWhere() } }),
|
||||||
: undefined,
|
prisma.review.count({ where: { ...filter, NOT: pendingReviewWhere() } }),
|
||||||
include: {
|
]);
|
||||||
product: { select: { name: true, slug: true } },
|
|
||||||
customer: { select: { firstName: true, lastName: true } },
|
|
||||||
},
|
|
||||||
orderBy: [{ isApproved: "asc" }, { createdAt: "desc" }],
|
|
||||||
take: 100,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<BackLink href="/admin" label={t.adminReviewsPage.backToDashboard} />
|
<BackLink href="/admin" label={s.backToDashboard} />
|
||||||
<AdminPageHeader title={t.adminReviewsPage.title} />
|
<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
|
<AdminSearchForm
|
||||||
query={query}
|
query={query}
|
||||||
label={t.adminReviewsPage.searchLabel}
|
label={s.searchLabel}
|
||||||
placeholder={t.adminReviewsPage.searchPlaceholder}
|
placeholder={s.searchPlaceholder}
|
||||||
submitLabel={t.adminReviewsPage.filterButton}
|
submitLabel={s.filterButton}
|
||||||
resetHref="/admin/bewertungen"
|
resetHref={isArchive ? `${BASE_PATH}?ansicht=archiv` : BASE_PATH}
|
||||||
resetLabel={t.adminReviewsPage.resetFilter}
|
resetLabel={s.resetFilter}
|
||||||
|
hiddenFields={isArchive ? { ansicht: "archiv" } : {}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="overflow-x-auto bg-background border border-ink/10">
|
{isArchive ? (
|
||||||
<table className="w-full text-sm">
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
<thead className="text-left text-ink/65 border-b border-ink/10">
|
<DecidedColumn title={s.columnApproved} reviews={approved} decision="approved" s={s} />
|
||||||
<tr>
|
<DecidedColumn title={s.columnRejected} reviews={rejected} decision="rejected" s={s} />
|
||||||
<th className="p-4">{t.adminReviewsPage.colProduct}</th>
|
</div>
|
||||||
<th className="p-4">{t.adminReviewsPage.colCustomer}</th>
|
) : (
|
||||||
<th className="p-4">{t.adminReviewsPage.colRating}</th>
|
<PendingTable reviews={pending} query={query} s={s} />
|
||||||
<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>
|
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
|
|||||||
import { noticeUrl } from "@/lib/notice";
|
import { noticeUrl } from "@/lib/notice";
|
||||||
import { getCurrentAdmin } from "@/lib/auth";
|
import { getCurrentAdmin } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { pendingReviewWhere } from "@/lib/review-status";
|
||||||
import { logoutAction } from "@/app/konto/actions";
|
import { logoutAction } from "@/app/konto/actions";
|
||||||
import { AdminShell } from "@/components/admin-shell";
|
import { AdminShell } from "@/components/admin-shell";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
@@ -58,7 +59,7 @@ export default async function AdminLayout({ children }: LayoutProps<"/admin">) {
|
|||||||
}),
|
}),
|
||||||
prisma.returnRequest.count({ where: { status: "ANGEFRAGT" } }),
|
prisma.returnRequest.count({ where: { status: "ANGEFRAGT" } }),
|
||||||
prisma.withdrawalDeclaration.count({ where: { processedAt: null } }),
|
prisma.withdrawalDeclaration.count({ where: { processedAt: null } }),
|
||||||
prisma.review.count({ where: { isApproved: false } }),
|
prisma.review.count({ where: pendingReviewWhere() }),
|
||||||
prisma.businessInquiry.count({ where: { status: "NEU" } }),
|
prisma.businessInquiry.count({ where: { status: "NEU" } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ApiCoreSyncPanel, ApiCoreSyncResult } from "@/components/apicore-sync-panel";
|
import { ApiCoreSyncPanel, ApiCoreSyncResult } from "@/components/apicore-sync-panel";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { pendingReviewWhere } from "@/lib/review-status";
|
||||||
import { COUNTED_SALE_WHERE } from "@/lib/sales";
|
import { COUNTED_SALE_WHERE } from "@/lib/sales";
|
||||||
import { formatPrice } from "@/lib/format";
|
import { formatPrice } from "@/lib/format";
|
||||||
import { getLocale } from "@/i18n/locale";
|
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 },
|
where: { status: { in: ["BEZAHLT", "IN_VORBEREITUNG", "VERSANDBEREIT", "ABHOLBEREIT"] }, archivedAt: null },
|
||||||
}),
|
}),
|
||||||
prisma.returnRequest.count({ where: { status: "ANGEFRAGT" } }),
|
prisma.returnRequest.count({ where: { status: "ANGEFRAGT" } }),
|
||||||
prisma.review.count({ where: { isApproved: false } }),
|
prisma.review.count({ where: pendingReviewWhere() }),
|
||||||
prisma.businessInquiry.count({ where: { status: "NEU" } }),
|
prisma.businessInquiry.count({ where: { status: "NEU" } }),
|
||||||
]);
|
]);
|
||||||
const toDo = [
|
const toDo = [
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export async function GET() {
|
|||||||
comment: r.comment,
|
comment: r.comment,
|
||||||
createdAt: r.createdAt,
|
createdAt: r.createdAt,
|
||||||
isApproved: r.isApproved,
|
isApproved: r.isApproved,
|
||||||
|
rejectedAt: r.rejectedAt,
|
||||||
})),
|
})),
|
||||||
wishlist: wishlistItems.map((w) => ({ product: w.product.name, addedAt: w.createdAt })),
|
wishlist: wishlistItems.map((w) => ({ product: w.product.name, addedAt: w.createdAt })),
|
||||||
newsletter: newsletter
|
newsletter: newsletter
|
||||||
|
|||||||
@@ -482,7 +482,11 @@ export default async function ProductDetailPage(
|
|||||||
</p>
|
</p>
|
||||||
) : ownReview ? (
|
) : ownReview ? (
|
||||||
<p className="text-sm text-ink/70">
|
<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>
|
</p>
|
||||||
) : !canReview ? (
|
) : !canReview ? (
|
||||||
<p className="text-sm text-ink/70">{t.productPage.onlyBuyersCanReview}</p>
|
<p className="text-sm text-ink/70">{t.productPage.onlyBuyersCanReview}</p>
|
||||||
|
|||||||
@@ -835,6 +835,14 @@ export const de: Dictionary = {
|
|||||||
deleteConfirm: "Diese Bewertung wirklich löschen?",
|
deleteConfirm: "Diese Bewertung wirklich löschen?",
|
||||||
delete: "Löschen",
|
delete: "Löschen",
|
||||||
noReviewsYet: "Noch keine Bewertungen vorhanden.",
|
noReviewsYet: "Noch keine Bewertungen vorhanden.",
|
||||||
|
statusRejected: "Abgelehnt",
|
||||||
|
reject: "Ablehnen",
|
||||||
|
rejectConfirm: "Diese Bewertung ablehnen? Sie wird nicht im Shop angezeigt und landet im Archiv.",
|
||||||
|
noPendingReviews: "Keine offenen Bewertungen — alles entschieden.",
|
||||||
|
columnApproved: "Akzeptiert",
|
||||||
|
columnRejected: "Abgelehnt",
|
||||||
|
rejectedOn: (date) => `Abgelehnt am ${date}`,
|
||||||
|
emptyColumn: "Keine Einträge.",
|
||||||
},
|
},
|
||||||
adminAuditLogPage: {
|
adminAuditLogPage: {
|
||||||
backToDashboard: "Zurück zum Dashboard",
|
backToDashboard: "Zurück zum Dashboard",
|
||||||
@@ -1102,6 +1110,10 @@ export const de: Dictionary = {
|
|||||||
"Angenommene, abgelehnte und erstattete Retouren landen nach einem Monat ohne Bearbeitung automatisch hier.",
|
"Angenommene, abgelehnte und erstattete Retouren landen nach einem Monat ohne Bearbeitung automatisch hier.",
|
||||||
autoRuleProducts:
|
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.",
|
"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.",
|
||||||
|
autoRuleInquiries:
|
||||||
|
"Abgeschlossene und abgelehnte Anfragen landen sofort hier. Wird der Status zurückgesetzt, kehrt die Anfrage in die aktive Liste zurück.",
|
||||||
|
autoRuleReviews:
|
||||||
|
"Freigeschaltete und abgelehnte Bewertungen landen sofort hier. Die Entscheidung lässt sich jederzeit umkehren.",
|
||||||
emptyArchive: "Das Archiv ist leer.",
|
emptyArchive: "Das Archiv ist leer.",
|
||||||
},
|
},
|
||||||
errorPage: {
|
errorPage: {
|
||||||
@@ -1341,6 +1353,7 @@ export const de: Dictionary = {
|
|||||||
unitsSuffix: (quantity) => `${quantity} Stück`,
|
unitsSuffix: (quantity) => `${quantity} Stück`,
|
||||||
deliveryDateLabel: "Liefertermin",
|
deliveryDateLabel: "Liefertermin",
|
||||||
noInquiriesYet: "Noch keine Firmenanfragen eingegangen.",
|
noInquiriesYet: "Noch keine Firmenanfragen eingegangen.",
|
||||||
|
noOpenInquiries: "Keine offenen Firmenanfragen.",
|
||||||
},
|
},
|
||||||
adminOrderDetailPage: {
|
adminOrderDetailPage: {
|
||||||
postNumberLabel: "DHL-Postnummer",
|
postNumberLabel: "DHL-Postnummer",
|
||||||
@@ -1876,6 +1889,7 @@ export const de: Dictionary = {
|
|||||||
loginToReview: "um dieses Produkt zu bewerten.",
|
loginToReview: "um dieses Produkt zu bewerten.",
|
||||||
alreadyReviewed: "Du hast dieses Produkt bereits bewertet.",
|
alreadyReviewed: "Du hast dieses Produkt bereits bewertet.",
|
||||||
alreadyReviewedPending: "Du hast dieses Produkt bereits bewertet, deine Bewertung wird noch geprüft.",
|
alreadyReviewedPending: "Du hast dieses Produkt bereits bewertet, deine Bewertung wird noch geprüft.",
|
||||||
|
alreadyReviewedRejected: "Du hast dieses Produkt bereits bewertet. Deine Bewertung wurde nicht veröffentlicht.",
|
||||||
onlyBuyersCanReview: "Nur Käufer:innen können dieses Produkt bewerten.",
|
onlyBuyersCanReview: "Nur Käufer:innen können dieses Produkt bewerten.",
|
||||||
yourRating: "Deine Bewertung",
|
yourRating: "Deine Bewertung",
|
||||||
commentPlaceholder: "Was hat dir gefallen? (optional)",
|
commentPlaceholder: "Was hat dir gefallen? (optional)",
|
||||||
|
|||||||
@@ -811,6 +811,14 @@ export const en: Dictionary = {
|
|||||||
deleteConfirm: "Really delete this review?",
|
deleteConfirm: "Really delete this review?",
|
||||||
delete: "Delete",
|
delete: "Delete",
|
||||||
noReviewsYet: "No reviews yet.",
|
noReviewsYet: "No reviews yet.",
|
||||||
|
statusRejected: "Rejected",
|
||||||
|
reject: "Reject",
|
||||||
|
rejectConfirm: "Reject this review? It won't be shown in the shop and moves to the archive.",
|
||||||
|
noPendingReviews: "No open reviews — everything has been decided.",
|
||||||
|
columnApproved: "Accepted",
|
||||||
|
columnRejected: "Rejected",
|
||||||
|
rejectedOn: (date) => `Rejected on ${date}`,
|
||||||
|
emptyColumn: "No entries.",
|
||||||
},
|
},
|
||||||
adminAuditLogPage: {
|
adminAuditLogPage: {
|
||||||
backToDashboard: "Back to dashboard",
|
backToDashboard: "Back to dashboard",
|
||||||
@@ -1077,6 +1085,10 @@ export const en: Dictionary = {
|
|||||||
autoRuleReturns: "Accepted, rejected and refunded returns move here automatically after one month without changes.",
|
autoRuleReturns: "Accepted, rejected and refunded returns move here automatically after one month without changes.",
|
||||||
autoRuleProducts:
|
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.",
|
"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.",
|
||||||
|
autoRuleInquiries:
|
||||||
|
"Completed and rejected inquiries move here right away. Resetting the status brings an inquiry back to the active list.",
|
||||||
|
autoRuleReviews:
|
||||||
|
"Approved and rejected reviews move here right away. The decision can be reversed at any time.",
|
||||||
emptyArchive: "The archive is empty.",
|
emptyArchive: "The archive is empty.",
|
||||||
},
|
},
|
||||||
errorPage: {
|
errorPage: {
|
||||||
@@ -1315,6 +1327,7 @@ export const en: Dictionary = {
|
|||||||
unitsSuffix: (quantity) => `${quantity} units`,
|
unitsSuffix: (quantity) => `${quantity} units`,
|
||||||
deliveryDateLabel: "Delivery date",
|
deliveryDateLabel: "Delivery date",
|
||||||
noInquiriesYet: "No business inquiries yet.",
|
noInquiriesYet: "No business inquiries yet.",
|
||||||
|
noOpenInquiries: "No open business inquiries.",
|
||||||
},
|
},
|
||||||
adminOrderDetailPage: {
|
adminOrderDetailPage: {
|
||||||
postNumberLabel: "DHL Post Number",
|
postNumberLabel: "DHL Post Number",
|
||||||
@@ -1850,6 +1863,7 @@ export const en: Dictionary = {
|
|||||||
loginToReview: "to review this product.",
|
loginToReview: "to review this product.",
|
||||||
alreadyReviewed: "You've already reviewed this product.",
|
alreadyReviewed: "You've already reviewed this product.",
|
||||||
alreadyReviewedPending: "You've already reviewed this product. Your review is still being checked.",
|
alreadyReviewedPending: "You've already reviewed this product. Your review is still being checked.",
|
||||||
|
alreadyReviewedRejected: "You've already reviewed this product. Your review was not published.",
|
||||||
onlyBuyersCanReview: "Only customers who purchased this product can review it.",
|
onlyBuyersCanReview: "Only customers who purchased this product can review it.",
|
||||||
yourRating: "Your rating",
|
yourRating: "Your rating",
|
||||||
commentPlaceholder: "What did you like? (optional)",
|
commentPlaceholder: "What did you like? (optional)",
|
||||||
|
|||||||
@@ -403,6 +403,14 @@ export interface Dictionary {
|
|||||||
deleteConfirm: string;
|
deleteConfirm: string;
|
||||||
delete: string;
|
delete: string;
|
||||||
noReviewsYet: string;
|
noReviewsYet: string;
|
||||||
|
statusRejected: string;
|
||||||
|
reject: string;
|
||||||
|
rejectConfirm: string;
|
||||||
|
noPendingReviews: string;
|
||||||
|
columnApproved: string;
|
||||||
|
columnRejected: string;
|
||||||
|
rejectedOn: (date: string) => string;
|
||||||
|
emptyColumn: string;
|
||||||
};
|
};
|
||||||
adminAuditLogPage: {
|
adminAuditLogPage: {
|
||||||
backToDashboard: string;
|
backToDashboard: string;
|
||||||
@@ -664,6 +672,8 @@ export interface Dictionary {
|
|||||||
autoRuleOrders: string;
|
autoRuleOrders: string;
|
||||||
autoRuleReturns: string;
|
autoRuleReturns: string;
|
||||||
autoRuleProducts: string;
|
autoRuleProducts: string;
|
||||||
|
autoRuleInquiries: string;
|
||||||
|
autoRuleReviews: string;
|
||||||
emptyArchive: string;
|
emptyArchive: string;
|
||||||
};
|
};
|
||||||
errorPage: {
|
errorPage: {
|
||||||
@@ -895,6 +905,7 @@ export interface Dictionary {
|
|||||||
unitsSuffix: (quantity: number) => string;
|
unitsSuffix: (quantity: number) => string;
|
||||||
deliveryDateLabel: string;
|
deliveryDateLabel: string;
|
||||||
noInquiriesYet: string;
|
noInquiriesYet: string;
|
||||||
|
noOpenInquiries: string;
|
||||||
};
|
};
|
||||||
adminOrderDetailPage: {
|
adminOrderDetailPage: {
|
||||||
postNumberLabel: string;
|
postNumberLabel: string;
|
||||||
@@ -1382,6 +1393,7 @@ export interface Dictionary {
|
|||||||
loginToReview: string;
|
loginToReview: string;
|
||||||
alreadyReviewed: string;
|
alreadyReviewed: string;
|
||||||
alreadyReviewedPending: string;
|
alreadyReviewedPending: string;
|
||||||
|
alreadyReviewedRejected: string;
|
||||||
onlyBuyersCanReview: string;
|
onlyBuyersCanReview: string;
|
||||||
yourRating: string;
|
yourRating: string;
|
||||||
commentPlaceholder: string;
|
commentPlaceholder: string;
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Aktiv/Archiv fuer Firmenanfragen.
|
||||||
|
*
|
||||||
|
* Abgeschlossene und abgelehnte Anfragen wandern ins Archiv, sobald der
|
||||||
|
* Status gesetzt ist. Wird der Status wieder auf "Neu" oder "In Bearbeitung"
|
||||||
|
* gestellt, steht die Anfrage automatisch wieder in der aktiven Liste — es
|
||||||
|
* gibt keine eigene Archiv-Markierung, die man vergessen koennte.
|
||||||
|
*/
|
||||||
|
import type { Prisma } from "@/generated/prisma/client";
|
||||||
|
|
||||||
|
export const INQUIRY_STATUSES = ["NEU", "IN_BEARBEITUNG", "ABGESCHLOSSEN", "ABGELEHNT"] as const;
|
||||||
|
export type InquiryStatusValue = (typeof INQUIRY_STATUSES)[number];
|
||||||
|
|
||||||
|
export const INQUIRY_DONE_STATUSES = ["ABGESCHLOSSEN", "ABGELEHNT"] as const satisfies readonly InquiryStatusValue[];
|
||||||
|
|
||||||
|
export function isInquiryStatus(value: string): value is InquiryStatusValue {
|
||||||
|
return (INQUIRY_STATUSES as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inquiryViewWhere(isArchive: boolean) {
|
||||||
|
return {
|
||||||
|
status: isArchive ? { in: [...INQUIRY_DONE_STATUSES] } : { notIn: [...INQUIRY_DONE_STATUSES] },
|
||||||
|
} satisfies Prisma.BusinessInquiryWhereInput;
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
reviewDecision,
|
||||||
|
pendingReviewWhere,
|
||||||
|
approvedReviewWhere,
|
||||||
|
rejectedReviewWhere,
|
||||||
|
} from "./review-status";
|
||||||
|
import { inquiryViewWhere, isInquiryStatus } from "./inquiry-status";
|
||||||
|
|
||||||
|
describe("reviewDecision", () => {
|
||||||
|
it("ist offen, solange weder freigeschaltet noch abgelehnt", () => {
|
||||||
|
expect(reviewDecision({ isApproved: false, rejectedAt: null })).toBe("pending");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ist abgelehnt, wenn rejectedAt gesetzt ist", () => {
|
||||||
|
expect(reviewDecision({ isApproved: false, rejectedAt: new Date("2026-10-01T00:00:00Z") })).toBe("rejected");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Freischaltung schlaegt eine fruehere Ablehnung", () => {
|
||||||
|
expect(reviewDecision({ isApproved: true, rejectedAt: new Date("2026-10-01T00:00:00Z") })).toBe("approved");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Bewertungs-Filter", () => {
|
||||||
|
it("offene Bewertungen: weder freigeschaltet noch abgelehnt", () => {
|
||||||
|
expect(pendingReviewWhere()).toEqual({ isApproved: false, rejectedAt: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("akzeptiert und abgelehnt ueberschneiden sich nicht", () => {
|
||||||
|
expect(approvedReviewWhere()).toEqual({ isApproved: true });
|
||||||
|
expect(rejectedReviewWhere()).toEqual({ isApproved: false, rejectedAt: { not: null } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("inquiryViewWhere", () => {
|
||||||
|
it("aktive Liste: alles ausser abgeschlossen und abgelehnt", () => {
|
||||||
|
expect(inquiryViewWhere(false)).toEqual({ status: { notIn: ["ABGESCHLOSSEN", "ABGELEHNT"] } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Archiv: abgeschlossen und abgelehnt", () => {
|
||||||
|
expect(inquiryViewWhere(true)).toEqual({ status: { in: ["ABGESCHLOSSEN", "ABGELEHNT"] } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isInquiryStatus", () => {
|
||||||
|
it("akzeptiert nur die vier bekannten Status", () => {
|
||||||
|
expect(isInquiryStatus("NEU")).toBe(true);
|
||||||
|
expect(isInquiryStatus("ABGELEHNT")).toBe(true);
|
||||||
|
expect(isInquiryStatus("GELOESCHT")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Bearbeitungsstand einer Bewertung im Admin.
|
||||||
|
*
|
||||||
|
* Eine Bewertung ist offen, bis der Admin sie freischaltet (isApproved) oder
|
||||||
|
* ablehnt (rejectedAt). Offene stehen in der aktiven Liste, entschiedene im
|
||||||
|
* Archiv — dort getrennt nach "Akzeptiert" und "Abgelehnt". Der Shop zeigt
|
||||||
|
* weiterhin ausschliesslich isApproved-Bewertungen (lib/reviews.ts).
|
||||||
|
*
|
||||||
|
* Anders als Bestellungen und Retouren (lib/archive.ts) gibt es hier keine
|
||||||
|
* Monatsfrist: eine entschiedene Bewertung braucht keine weitere Arbeit, sie
|
||||||
|
* gehoert sofort ins Archiv.
|
||||||
|
*/
|
||||||
|
import type { Prisma } from "@/generated/prisma/client";
|
||||||
|
|
||||||
|
export type ReviewDecision = "pending" | "approved" | "rejected";
|
||||||
|
|
||||||
|
export function reviewDecision(review: { isApproved: boolean; rejectedAt: Date | null }): ReviewDecision {
|
||||||
|
if (review.isApproved) return "approved";
|
||||||
|
if (review.rejectedAt) return "rejected";
|
||||||
|
return "pending";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pendingReviewWhere() {
|
||||||
|
return { isApproved: false, rejectedAt: null } satisfies Prisma.ReviewWhereInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function approvedReviewWhere() {
|
||||||
|
return { isApproved: true } satisfies Prisma.ReviewWhereInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rejectedReviewWhere() {
|
||||||
|
return { isApproved: false, rejectedAt: { not: null } } satisfies Prisma.ReviewWhereInput;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user