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;