From 8f0f06fd134bad372cb3ab2371493537226d9458 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Thu, 17 Sep 2026 18:10:09 +0200 Subject: [PATCH] feat: Produkte loeschen, und ein sichtbarer Grund, wenn es nicht geht MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fuer Ernten und Chargen gab es das Loeschen bereits, fuer Produkte nicht. Neue Server Action deleteProductAction samt Knopf in der Produktliste, im selben Muster wie die beiden vorhandenen: Bestaetigungsdialog, Audit-Log-Eintrag, Pruefung vor dem Loeschen. Geloescht werden kann nur, was nie bestellt wurde. OrderItem verweist mit onDelete: Restrict auf das Produkt, und das ist Absicht: Bestellpositionen und die daraus erzeugten Rechnungen sind Belege ueber einen vergangenen Vorgang. Verschwaende das Produkt darunter weg, verloere eine bereits gestellte Rechnung ihren Bezug — bei zehn Jahren Aufbewahrungspflicht (§ 147 AO) keine Option. Die Meldung sagt deshalb nicht nur "geht nicht", sondern nennt den Ausweg: inaktiv schalten, dann verschwindet das Produkt aus dem Shop und der Beleg bleibt. Mit dem Produkt gehen Lagerbestand, Bewertungen, Wunschlisten-Eintraege und Verfuegbarkeits-Benachrichtigungen (onDelete: Cascade). Hochgeladene Bilder bleiben liegen: sie koennen anderswo eingebunden sein, und eine verwaiste Datei ist harmloser als ein totes Bild an anderer Stelle. Auf allen drei Seiten fehlte bisher ausserdem die Erklaerung, warum der Loeschknopf manchmal gar nicht da ist — er verschwand wortlos, sobald etwas darauf verwies. Jetzt steht an seiner Stelle der Grund ("bereits verkauft" bzw. "in Verwendung"). Co-Authored-By: Claude Opus 5 (1M context) --- src/app/admin/actions/products.ts | 49 +++++++++++++++++++++++++++++++ src/app/admin/chargen/page.tsx | 5 +++- src/app/admin/ernten/page.tsx | 5 +++- src/app/admin/produkte/page.tsx | 34 ++++++++++++++++++--- src/i18n/dictionaries/de.ts | 6 ++++ src/i18n/dictionaries/en.ts | 5 ++++ src/i18n/dictionary-type.ts | 5 ++++ 7 files changed, 103 insertions(+), 6 deletions(-) diff --git a/src/app/admin/actions/products.ts b/src/app/admin/actions/products.ts index fa8518c..5e53868 100644 --- a/src/app/admin/actions/products.ts +++ b/src/app/admin/actions/products.ts @@ -104,6 +104,55 @@ export async function createProductAction(formData: FormData): Promise { redirect("/admin/produkte"); } +/** + * Loescht ein Produkt endgueltig. + * + * Nur moeglich, solange es nie bestellt wurde. OrderItem verweist mit + * onDelete: Restrict auf das Produkt (prisma/schema.prisma), und das ist + * Absicht: Bestellpositionen und die daraus erzeugten Rechnungen sind Belege + * ueber einen vergangenen Vorgang. Verschwaende das Produkt darunter weg, + * verloere eine bereits gestellte Rechnung ihren Bezug — bei zehn Jahren + * Aufbewahrungspflicht (§ 147 AO) keine Option. + * + * Wer ein bereits verkauftes Produkt aus dem Shop nehmen will, schaltet es + * deshalb inaktiv, statt es zu loeschen. Genau das sagt die Meldung. + * + * Was mitgeht: Lagerbestand, Bewertungen, Wunschlisten-Eintraege und + * Verfuegbarkeits-Benachrichtigungen haengen mit onDelete: Cascade am + * Produkt. Hochgeladene Bilder bleiben dagegen im Upload-Verzeichnis liegen + * — sie koennen auch anderswo eingebunden sein, und eine verwaiste Datei ist + * harmloser als ein totes Bild an anderer Stelle. + */ +export async function deleteProductAction(formData: FormData): Promise { + const admin = await requireAdmin(); + const id = str(formData, "id"); + + const product = await prisma.product.findUnique({ + where: { id }, + include: { _count: { select: { orderItems: true } } }, + }); + if (!product) fail("/admin/produkte", "Produkt nicht gefunden."); + + if (product._count.orderItems > 0) { + fail( + "/admin/produkte", + `"${product.name}" wurde bereits in ${product._count.orderItems} Bestellposition(en) verkauft und kann deshalb nicht gelöscht werden — die zugehörigen Rechnungen müssen nachvollziehbar bleiben. Setze das Produkt stattdessen auf "nicht aktiv", dann verschwindet es aus dem Shop.` + ); + } + + await prisma.product.delete({ where: { id } }); + + await logAdminAction(admin, { + action: "product.delete", + entityType: "Product", + entityId: id, + detail: product.name, + }); + + revalidatePath("/admin/produkte"); + redirect("/admin/produkte"); +} + export async function updateProductAction(formData: FormData): Promise { const admin = await requireAdmin(); const id = str(formData, "id"); diff --git a/src/app/admin/chargen/page.tsx b/src/app/admin/chargen/page.tsx index 6290c6d..6cb181d 100644 --- a/src/app/admin/chargen/page.tsx +++ b/src/app/admin/chargen/page.tsx @@ -64,7 +64,10 @@ export default async function AdminBatchesPage(props: PageProps<"/admin/chargen" {t.adminBatchesPage.delete} - ) : null} + ) : ( + // Statt den Knopf wortlos wegzulassen: der Grund steht da. + {t.adminBatchesPage.notDeletable} + )} ))} diff --git a/src/app/admin/ernten/page.tsx b/src/app/admin/ernten/page.tsx index 599715c..227e709 100644 --- a/src/app/admin/ernten/page.tsx +++ b/src/app/admin/ernten/page.tsx @@ -106,7 +106,10 @@ export default async function AdminHarvestsPage(props: PageProps<"/admin/ernten" {t.adminHarvestsPage.delete} - ) : null} + ) : ( + // Statt den Knopf wortlos wegzulassen: der Grund steht da. + {t.adminHarvestsPage.notDeletable} + )} ))} diff --git a/src/app/admin/produkte/page.tsx b/src/app/admin/produkte/page.tsx index 2350b3f..f1cfb39 100644 --- a/src/app/admin/produkte/page.tsx +++ b/src/app/admin/produkte/page.tsx @@ -2,7 +2,8 @@ import Link from "next/link"; import { prisma } from "@/lib/prisma"; import { BackLink } from "@/components/back-link"; import { formatPrice } from "@/lib/format"; -import { syncProductsFromApiCoreAction } from "../actions"; +import { ConfirmForm } from "@/components/confirm-form"; +import { deleteProductAction, syncProductsFromApiCoreAction } from "../actions"; import { getLocale } from "@/i18n/locale"; import { getDictionary } from "@/i18n/get-dictionary"; @@ -10,7 +11,13 @@ export default async function AdminProductsPage(props: PageProps<"/admin/produkt const t = getDictionary(await getLocale()); const { synced, skipped, failed, discontinued, pending, syncError } = await props.searchParams; const products = await prisma.product.findMany({ - include: { harvest: true, batch: true, inventory: true }, + include: { + harvest: true, + batch: true, + inventory: true, + // Entscheidet, ob geloescht werden darf — siehe Loeschknopf unten. + _count: { select: { orderItems: true } }, + }, orderBy: { createdAt: "desc" }, }); @@ -84,10 +91,29 @@ export default async function AdminProductsPage(props: PageProps<"/admin/produkt {t.adminProductsPage.draftHint} ) : null} - - + + {t.adminProductsPage.edit} + {/* Loeschen nur, solange nie bestellt: OrderItem verweist mit + onDelete: Restrict auf das Produkt, damit gestellte + Rechnungen ihren Bezug behalten (siehe + admin/actions/products.ts). Statt den Knopf wortlos + wegzulassen, steht dann der Grund da. */} + {product._count.orderItems === 0 ? ( + + + + + ) : ( + {t.adminProductsPage.notDeletable} + )} ))} diff --git a/src/i18n/dictionaries/de.ts b/src/i18n/dictionaries/de.ts index 5049696..4c44a96 100644 --- a/src/i18n/dictionaries/de.ts +++ b/src/i18n/dictionaries/de.ts @@ -811,6 +811,7 @@ export const de: Dictionary = { }, adminBatchesPage: { backToDashboard: "Zurück zum Dashboard", + notDeletable: "in Verwendung", title: "Chargen", colBatch: "Charge", colHarvest: "Ernte", @@ -877,6 +878,7 @@ export const de: Dictionary = { }, adminHarvestsPage: { backToDashboard: "Zurück zum Dashboard", + notDeletable: "in Verwendung", title: "Ernten", colType: "Typ", colYear: "Jahr", @@ -931,6 +933,10 @@ export const de: Dictionary = { }, adminProductsPage: { backToDashboard: "Zurück zum Dashboard", + delete: "Löschen", + deleteConfirm: (label) => + `"${label}" wirklich löschen? Dies kann nicht rückgängig gemacht werden.`, + notDeletable: "bereits verkauft", title: "Produkte", newProduct: "Neues Produkt", colProduct: "Produkt", diff --git a/src/i18n/dictionaries/en.ts b/src/i18n/dictionaries/en.ts index e01172a..c7a61fa 100644 --- a/src/i18n/dictionaries/en.ts +++ b/src/i18n/dictionaries/en.ts @@ -811,6 +811,7 @@ export const en: Dictionary = { }, adminBatchesPage: { backToDashboard: "Back to dashboard", + notDeletable: "in use", title: "Batches", colBatch: "Batch", colHarvest: "Harvest", @@ -877,6 +878,7 @@ export const en: Dictionary = { }, adminHarvestsPage: { backToDashboard: "Back to dashboard", + notDeletable: "in use", title: "Harvests", colType: "Type", colYear: "Year", @@ -931,6 +933,9 @@ export const en: Dictionary = { }, adminProductsPage: { backToDashboard: "Back to dashboard", + delete: "Delete", + deleteConfirm: (label) => `Really delete "${label}"? This cannot be undone.`, + notDeletable: "already sold", title: "Products", newProduct: "New product", colProduct: "Product", diff --git a/src/i18n/dictionary-type.ts b/src/i18n/dictionary-type.ts index 70af839..67da443 100644 --- a/src/i18n/dictionary-type.ts +++ b/src/i18n/dictionary-type.ts @@ -514,6 +514,7 @@ export interface Dictionary { }; adminBatchesPage: { backToDashboard: string; + notDeletable: string; title: string; colBatch: string; colHarvest: string; @@ -580,6 +581,7 @@ export interface Dictionary { }; adminHarvestsPage: { backToDashboard: string; + notDeletable: string; title: string; colType: string; colYear: string; @@ -633,6 +635,9 @@ export interface Dictionary { }; adminProductsPage: { backToDashboard: string; + delete: string; + deleteConfirm: (label: string) => string; + notDeletable: string; title: string; newProduct: string; colProduct: string;