diff --git a/prisma/migrations/20261002010000_review_rejected_at/migration.sql b/prisma/migrations/20261002010000_review_rejected_at/migration.sql new file mode 100644 index 0000000..edae734 --- /dev/null +++ b/prisma/migrations/20261002010000_review_rejected_at/migration.sql @@ -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); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 658a45a..8fb5b79 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -326,6 +326,10 @@ model Review { /// Nur freigeschaltete Bewertungen werden auf der Produktseite angezeigt — /// einfache Missbrauchskontrolle durch den Admin vor Veroeffentlichung. 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()) updatedAt DateTime @updatedAt diff --git a/src/app/admin/actions/inquiries.ts b/src/app/admin/actions/inquiries.ts index 4ee7423..6cfac37 100644 --- a/src/app/admin/actions/inquiries.ts +++ b/src/app/admin/actions/inquiries.ts @@ -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 { 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, { diff --git a/src/app/admin/actions/reviews.ts b/src/app/admin/actions/reviews.ts index a476ee2..8806487 100644 --- a/src/app/admin/actions/reviews.ts +++ b/src/app/admin/actions/reviews.ts @@ -12,7 +12,7 @@ export async function approveReviewAction(formData: FormData): Promise { 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 { 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 { + 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 { const admin = await requireAdmin(); const id = str(formData, "id"); diff --git a/src/app/admin/anfragen/page.tsx b/src/app/admin/anfragen/page.tsx index 11b4efb..e27fe69 100644 --- a/src/app/admin/anfragen/page.tsx +++ b/src/app/admin/anfragen/page.tsx @@ -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 (
+ + {isArchive ?

{t.adminArchive.autoRuleInquiries}

: null} +
{inquiries.map((inquiry) => ( @@ -119,7 +144,15 @@ export default async function AdminInquiriesPage(props: PageProps<"/admin/anfrag {inquiries.length === 0 ? ( 0 + ? t.adminInquiriesPage.noOpenInquiries + : t.adminInquiriesPage.noInquiriesYet + } /> ) : null}
diff --git a/src/app/admin/bewertungen/page.tsx b/src/app/admin/bewertungen/page.tsx index 15ee4d9..bcc3ae8 100644 --- a/src/app/admin/bewertungen/page.tsx +++ b/src/app/admin/bewertungen/page.tsx @@ -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 (
- - + + + + + {isArchive ?

{t.adminArchive.autoRuleReviews}

: null} -
- - - - - - - - - - - - - {reviews.map((review) => ( - - - - - - - - - - ))} - -
{t.adminReviewsPage.colProduct}{t.adminReviewsPage.colCustomer}{t.adminReviewsPage.colRating}{t.adminReviewsPage.colComment}{t.adminReviewsPage.colDate}{t.adminReviewsPage.colStatus} -
- - {review.product.name} - - - {review.customer.firstName} {review.customer.lastName} - - - {review.rating} / 5 - {review.comment ?? "—"}{formatDate(review.createdAt)} - {review.isApproved ? ( - {t.adminReviewsPage.statusPublished} - ) : ( - - {t.adminReviewsPage.statusPending} - - )} - - - {!review.isApproved ? ( -
- - {t.adminReviewsPage.approve} -
- ) : null} - - - {t.adminReviewsPage.delete} - -
-
- {reviews.length === 0 ? ( - - ) : null} -
+ {isArchive ? ( +
+ + +
+ ) : ( + + )}
); } + +function PendingTable({ reviews, query, s }: { reviews: AdminReview[]; query: string; s: ReviewTexts }) { + return ( +
+ + + + + + + + + + + + + {reviews.map((review) => ( + + + + + + + + + + ))} + +
{s.colProduct}{s.colCustomer}{s.colRating}{s.colComment}{s.colDate}{s.colStatus} +
+ + + {review.customer.firstName} {review.customer.lastName} + + + {review.comment ?? "—"}{formatDate(review.createdAt)} + + {s.statusPending} + + + +
+ {reviews.length === 0 ? ( + + ) : null} +
+ ); +} + +function DecidedColumn({ + title, + reviews, + decision, + s, +}: { + title: string; + reviews: AdminReview[]; + decision: "approved" | "rejected"; + s: ReviewTexts; +}) { + return ( +
+

+ {title} + {reviews.length} +

+ {reviews.length === 0 ? ( +

{s.emptyColumn}

+ ) : ( +
    + {reviews.map((review) => ( +
  • +
    + + +
    +

    + {review.customer.firstName} {review.customer.lastName} ·{" "} + {formatDate(review.createdAt)} +

    + {review.comment ?

    {review.comment}

    : null} + {decision === "rejected" && review.rejectedAt ? ( +

    {s.rejectedOn(formatDate(review.rejectedAt))}

    + ) : null} + +
  • + ))} +
+ )} +
+ ); +} + +function ProductLink({ review }: { review: AdminReview }) { + return ( + + {review.product.name} + + ); +} + +function Stars({ rating }: { rating: number }) { + return ( + + + {rating} / 5 + + ); +} + +/** 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 ( + + {decision !== "approved" ? ( +
+ + {s.approve} +
+ ) : null} + {decision !== "rejected" ? ( + + + {s.reject} + + ) : null} + + + {s.delete} + +
+ ); +} diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index e9b27d4..0b62c18 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -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" } }), ]); diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index d9ad670..77f7bca 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -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 = [ diff --git a/src/app/api/konto/datenexport/route.ts b/src/app/api/konto/datenexport/route.ts index ec932f3..ed6e9c7 100644 --- a/src/app/api/konto/datenexport/route.ts +++ b/src/app/api/konto/datenexport/route.ts @@ -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 diff --git a/src/app/honig/[slug]/page.tsx b/src/app/honig/[slug]/page.tsx index ee87d9b..08e0835 100644 --- a/src/app/honig/[slug]/page.tsx +++ b/src/app/honig/[slug]/page.tsx @@ -482,7 +482,11 @@ export default async function ProductDetailPage(

) : ownReview ? (

- {!ownReview.isApproved ? t.productPage.alreadyReviewedPending : t.productPage.alreadyReviewed} + {ownReview.isApproved + ? t.productPage.alreadyReviewed + : ownReview.rejectedAt + ? t.productPage.alreadyReviewedRejected + : t.productPage.alreadyReviewedPending}

) : !canReview ? (

{t.productPage.onlyBuyersCanReview}

diff --git a/src/i18n/dictionaries/de.ts b/src/i18n/dictionaries/de.ts index e30dbf0..b964ab5 100644 --- a/src/i18n/dictionaries/de.ts +++ b/src/i18n/dictionaries/de.ts @@ -835,6 +835,14 @@ export const de: Dictionary = { deleteConfirm: "Diese Bewertung wirklich löschen?", delete: "Löschen", 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: { 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.", 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.", + 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.", }, errorPage: { @@ -1341,6 +1353,7 @@ export const de: Dictionary = { unitsSuffix: (quantity) => `${quantity} Stück`, deliveryDateLabel: "Liefertermin", noInquiriesYet: "Noch keine Firmenanfragen eingegangen.", + noOpenInquiries: "Keine offenen Firmenanfragen.", }, adminOrderDetailPage: { postNumberLabel: "DHL-Postnummer", @@ -1876,6 +1889,7 @@ export const de: Dictionary = { loginToReview: "um dieses Produkt zu bewerten.", alreadyReviewed: "Du hast dieses Produkt bereits bewertet.", 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.", yourRating: "Deine Bewertung", commentPlaceholder: "Was hat dir gefallen? (optional)", diff --git a/src/i18n/dictionaries/en.ts b/src/i18n/dictionaries/en.ts index a431fa1..6a08f90 100644 --- a/src/i18n/dictionaries/en.ts +++ b/src/i18n/dictionaries/en.ts @@ -811,6 +811,14 @@ export const en: Dictionary = { deleteConfirm: "Really delete this review?", delete: "Delete", 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: { 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.", 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.", + 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.", }, errorPage: { @@ -1315,6 +1327,7 @@ export const en: Dictionary = { unitsSuffix: (quantity) => `${quantity} units`, deliveryDateLabel: "Delivery date", noInquiriesYet: "No business inquiries yet.", + noOpenInquiries: "No open business inquiries.", }, adminOrderDetailPage: { postNumberLabel: "DHL Post Number", @@ -1850,6 +1863,7 @@ export const en: Dictionary = { loginToReview: "to review this product.", alreadyReviewed: "You've already reviewed this product.", 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.", yourRating: "Your rating", commentPlaceholder: "What did you like? (optional)", diff --git a/src/i18n/dictionary-type.ts b/src/i18n/dictionary-type.ts index d775fac..3a9ce20 100644 --- a/src/i18n/dictionary-type.ts +++ b/src/i18n/dictionary-type.ts @@ -403,6 +403,14 @@ export interface Dictionary { deleteConfirm: string; delete: string; noReviewsYet: string; + statusRejected: string; + reject: string; + rejectConfirm: string; + noPendingReviews: string; + columnApproved: string; + columnRejected: string; + rejectedOn: (date: string) => string; + emptyColumn: string; }; adminAuditLogPage: { backToDashboard: string; @@ -664,6 +672,8 @@ export interface Dictionary { autoRuleOrders: string; autoRuleReturns: string; autoRuleProducts: string; + autoRuleInquiries: string; + autoRuleReviews: string; emptyArchive: string; }; errorPage: { @@ -895,6 +905,7 @@ export interface Dictionary { unitsSuffix: (quantity: number) => string; deliveryDateLabel: string; noInquiriesYet: string; + noOpenInquiries: string; }; adminOrderDetailPage: { postNumberLabel: string; @@ -1382,6 +1393,7 @@ export interface Dictionary { loginToReview: string; alreadyReviewed: string; alreadyReviewedPending: string; + alreadyReviewedRejected: string; onlyBuyersCanReview: string; yourRating: string; commentPlaceholder: string; diff --git a/src/lib/inquiry-status.ts b/src/lib/inquiry-status.ts new file mode 100644 index 0000000..0e593d1 --- /dev/null +++ b/src/lib/inquiry-status.ts @@ -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; +} diff --git a/src/lib/review-inquiry-status.test.ts b/src/lib/review-inquiry-status.test.ts new file mode 100644 index 0000000..abecf8e --- /dev/null +++ b/src/lib/review-inquiry-status.test.ts @@ -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); + }); +}); diff --git a/src/lib/review-status.ts b/src/lib/review-status.ts new file mode 100644 index 0000000..a314c97 --- /dev/null +++ b/src/lib/review-status.ts @@ -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; +}