feat: Produkte loeschen, und ein sichtbarer Grund, wenn es nicht geht

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) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5 committed 2026-09-17 18:10:09 +02:00
1 parent dc13bd071b
commit 8f0f06fd13
7 files changed
+103 -6

No files matched your search

+49
View File
@@ -104,6 +104,55 @@ export async function createProductAction(formData: FormData): Promise<void> {
redirect("/admin/produkte"); 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<void> {
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<void> { export async function updateProductAction(formData: FormData): Promise<void> {
const admin = await requireAdmin(); const admin = await requireAdmin();
const id = str(formData, "id"); const id = str(formData, "id");
+4 -1
View File
@@ -64,7 +64,10 @@ export default async function AdminBatchesPage(props: PageProps<"/admin/chargen"
{t.adminBatchesPage.delete} {t.adminBatchesPage.delete}
</button> </button>
</ConfirmForm> </ConfirmForm>
) : null} ) : (
// Statt den Knopf wortlos wegzulassen: der Grund steht da.
<span className="text-xs text-ink/50">{t.adminBatchesPage.notDeletable}</span>
)}
</td> </td>
</tr> </tr>
))} ))}
+4 -1
View File
@@ -106,7 +106,10 @@ export default async function AdminHarvestsPage(props: PageProps<"/admin/ernten"
{t.adminHarvestsPage.delete} {t.adminHarvestsPage.delete}
</button> </button>
</ConfirmForm> </ConfirmForm>
) : null} ) : (
// Statt den Knopf wortlos wegzulassen: der Grund steht da.
<span className="text-xs text-ink/50">{t.adminHarvestsPage.notDeletable}</span>
)}
</td> </td>
</tr> </tr>
))} ))}
+30 -4
View File
@@ -2,7 +2,8 @@ import Link from "next/link";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
import { formatPrice } from "@/lib/format"; 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 { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; 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 t = getDictionary(await getLocale());
const { synced, skipped, failed, discontinued, pending, syncError } = await props.searchParams; const { synced, skipped, failed, discontinued, pending, syncError } = await props.searchParams;
const products = await prisma.product.findMany({ 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" }, orderBy: { createdAt: "desc" },
}); });
@@ -84,10 +91,29 @@ export default async function AdminProductsPage(props: PageProps<"/admin/produkt
<span className="block text-xs text-honey-dark">{t.adminProductsPage.draftHint}</span> <span className="block text-xs text-honey-dark">{t.adminProductsPage.draftHint}</span>
) : null} ) : null}
</td> </td>
<td className="p-4"> <td className="p-4 whitespace-nowrap">
<Link href={`/admin/produkte/${product.id}`} className="text-forest underline"> <Link href={`/admin/produkte/${product.id}`} className="text-forest underline mr-4">
{t.adminProductsPage.edit} {t.adminProductsPage.edit}
</Link> </Link>
{/* 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 ? (
<ConfirmForm
action={deleteProductAction}
confirmMessage={t.adminProductsPage.deleteConfirm(product.name)}
className="inline"
>
<input type="hidden" name="id" value={product.id} />
<button type="submit" className="text-red-700 underline">
{t.adminProductsPage.delete}
</button>
</ConfirmForm>
) : (
<span className="text-xs text-ink/50">{t.adminProductsPage.notDeletable}</span>
)}
</td> </td>
</tr> </tr>
))} ))}
+6
View File
@@ -811,6 +811,7 @@ export const de: Dictionary = {
}, },
adminBatchesPage: { adminBatchesPage: {
backToDashboard: "Zurück zum Dashboard", backToDashboard: "Zurück zum Dashboard",
notDeletable: "in Verwendung",
title: "Chargen", title: "Chargen",
colBatch: "Charge", colBatch: "Charge",
colHarvest: "Ernte", colHarvest: "Ernte",
@@ -877,6 +878,7 @@ export const de: Dictionary = {
}, },
adminHarvestsPage: { adminHarvestsPage: {
backToDashboard: "Zurück zum Dashboard", backToDashboard: "Zurück zum Dashboard",
notDeletable: "in Verwendung",
title: "Ernten", title: "Ernten",
colType: "Typ", colType: "Typ",
colYear: "Jahr", colYear: "Jahr",
@@ -931,6 +933,10 @@ export const de: Dictionary = {
}, },
adminProductsPage: { adminProductsPage: {
backToDashboard: "Zurück zum Dashboard", 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", title: "Produkte",
newProduct: "Neues Produkt", newProduct: "Neues Produkt",
colProduct: "Produkt", colProduct: "Produkt",
+5
View File
@@ -811,6 +811,7 @@ export const en: Dictionary = {
}, },
adminBatchesPage: { adminBatchesPage: {
backToDashboard: "Back to dashboard", backToDashboard: "Back to dashboard",
notDeletable: "in use",
title: "Batches", title: "Batches",
colBatch: "Batch", colBatch: "Batch",
colHarvest: "Harvest", colHarvest: "Harvest",
@@ -877,6 +878,7 @@ export const en: Dictionary = {
}, },
adminHarvestsPage: { adminHarvestsPage: {
backToDashboard: "Back to dashboard", backToDashboard: "Back to dashboard",
notDeletable: "in use",
title: "Harvests", title: "Harvests",
colType: "Type", colType: "Type",
colYear: "Year", colYear: "Year",
@@ -931,6 +933,9 @@ export const en: Dictionary = {
}, },
adminProductsPage: { adminProductsPage: {
backToDashboard: "Back to dashboard", backToDashboard: "Back to dashboard",
delete: "Delete",
deleteConfirm: (label) => `Really delete "${label}"? This cannot be undone.`,
notDeletable: "already sold",
title: "Products", title: "Products",
newProduct: "New product", newProduct: "New product",
colProduct: "Product", colProduct: "Product",
+5
View File
@@ -514,6 +514,7 @@ export interface Dictionary {
}; };
adminBatchesPage: { adminBatchesPage: {
backToDashboard: string; backToDashboard: string;
notDeletable: string;
title: string; title: string;
colBatch: string; colBatch: string;
colHarvest: string; colHarvest: string;
@@ -580,6 +581,7 @@ export interface Dictionary {
}; };
adminHarvestsPage: { adminHarvestsPage: {
backToDashboard: string; backToDashboard: string;
notDeletable: string;
title: string; title: string;
colType: string; colType: string;
colYear: string; colYear: string;
@@ -633,6 +635,9 @@ export interface Dictionary {
}; };
adminProductsPage: { adminProductsPage: {
backToDashboard: string; backToDashboard: string;
delete: string;
deleteConfirm: (label: string) => string;
notDeletable: string;
title: string; title: string;
newProduct: string; newProduct: string;
colProduct: string; colProduct: string;