From a3b11cc8e202f9f1b99b7ce10440a970608b5690 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Fri, 11 Sep 2026 23:45:23 +0200 Subject: [PATCH] feat: Ernten und Chargen im Admin loeschbar machen Neue deleteHarvestAction/deleteBatchAction, jeweils mit vorheriger Referenzpruefung statt rohem Datenbankfehler: eine Ernte laesst sich nur loeschen, wenn keine Chargen/Produkte mehr darauf verweisen, eine Charge nur, wenn sie keinem Produkt zugeordnet ist und in keiner Bestellung verwendet wurde (Chargen-Code steht auf Rechnungen/QR-Codes und darf aus Nachweispflicht nie verschwinden). Loeschen-Button erscheint in den Listen nur, wenn die Loeschung tatsaechlich moeglich ist, mit Bestaetigungsdialog wie beim bestehenden Benutzer-Loeschen. Co-Authored-By: Claude Sonnet 5 --- src/app/admin/actions/harvests-batches.ts | 81 ++++++++++++++++++++++- src/app/admin/chargen/page.tsx | 27 ++++++-- src/app/admin/ernten/page.tsx | 27 +++++++- src/i18n/dictionaries/de.ts | 4 ++ src/i18n/dictionaries/en.ts | 4 ++ src/i18n/dictionary-type.ts | 4 ++ 6 files changed, 138 insertions(+), 9 deletions(-) diff --git a/src/app/admin/actions/harvests-batches.ts b/src/app/admin/actions/harvests-batches.ts index d7f4152..6becd5a 100644 --- a/src/app/admin/actions/harvests-batches.ts +++ b/src/app/admin/actions/harvests-batches.ts @@ -5,7 +5,7 @@ import { revalidatePath } from "next/cache"; import { prisma } from "@/lib/prisma"; import { logAdminAction } from "@/lib/audit-log"; import { isValidUploadPath } from "@/lib/uploads"; -import { requireAdmin, str, num, strArray } from "./_shared"; +import { requireAdmin, fail, str, num, strArray } from "./_shared"; export async function createHarvestAction(formData: FormData): Promise { const admin = await requireAdmin(); @@ -68,6 +68,45 @@ export async function updateHarvestAction(formData: FormData): Promise { redirect("/admin/ernten"); } +/** + * Loescht eine Ernte unwiderruflich. harvestId ist auf Batch UND + * harvestId/batchId auf Product jeweils onDelete: Restrict (siehe + * prisma/schema.prisma) — die Datenbank wuerde ein direktes delete() also + * ohnehin ablehnen, sobald noch Chargen oder Produkte auf diese Ernte + * verweisen. Die Zaehlung hier VOR dem Loeschversuch liefert dafuer eine + * konkrete, verstaendliche Fehlermeldung statt eines rohen Datenbankfehlers. + */ +export async function deleteHarvestAction(formData: FormData): Promise { + const admin = await requireAdmin(); + const id = str(formData, "id"); + + const harvest = await prisma.harvest.findUnique({ + where: { id }, + include: { _count: { select: { batches: true, products: true } } }, + }); + if (!harvest) fail("/admin/ernten", "Ernte nicht gefunden."); + + if (harvest._count.batches > 0 || harvest._count.products > 0) { + fail( + "/admin/ernten", + `Diese Ernte hat noch ${harvest._count.batches} Charge(n) und ${harvest._count.products} Produkt(e). Lösche oder verschiebe diese zuerst.` + ); + } + + await prisma.harvest.delete({ where: { id } }); + + await logAdminAction(admin, { + action: "harvest.delete", + entityType: "Harvest", + entityId: id, + detail: `${harvest.type} ${harvest.year}`, + }); + + revalidatePath("/admin/ernten"); + revalidatePath("/ernten"); + redirect("/admin/ernten"); +} + /** * Isolierte "Veroeffentlichen"/"Verbergen"-Aktion, getrennt von * updateHarvestAction — so kann der Status per Knopfdruck direkt aus der @@ -136,3 +175,43 @@ export async function updateBatchAction(formData: FormData): Promise { revalidatePath("/admin/chargen"); redirect("/admin/chargen"); } + +/** + * Loescht eine Charge unwiderruflich. productId/batchId auf OrderItem sind + * onDelete: Restrict (siehe prisma/schema.prisma) — eine Charge, die schon + * in mindestens einer Bestellposition verwendet wurde, darf aus + * Rechnungs-/Nachweispflicht NIE geloescht werden (Chargen-Code steht auf + * dem QR-Code des Glases und in Rechnungen). Batch.products ist ebenfalls + * Restrict. Die Zaehlung hier VOR dem Loeschversuch liefert dafuer eine + * konkrete, verstaendliche Fehlermeldung statt eines rohen Datenbankfehlers. + */ +export async function deleteBatchAction(formData: FormData): Promise { + const admin = await requireAdmin(); + const id = str(formData, "id"); + + const batch = await prisma.batch.findUnique({ + where: { id }, + include: { _count: { select: { products: true, orderItems: true } } }, + }); + if (!batch) fail("/admin/chargen", "Charge nicht gefunden."); + + if (batch._count.orderItems > 0) { + fail( + "/admin/chargen", + `Charge "${batch.code}" wurde bereits in ${batch._count.orderItems} Bestellposition(en) verwendet und kann deshalb nicht gelöscht werden.` + ); + } + if (batch._count.products > 0) { + fail( + "/admin/chargen", + `Charge "${batch.code}" ist noch ${batch._count.products} Produkt(en) zugeordnet. Löse diese Zuordnung zuerst.` + ); + } + + await prisma.batch.delete({ where: { id } }); + + await logAdminAction(admin, { action: "batch.delete", entityType: "Batch", entityId: id, detail: batch.code }); + + revalidatePath("/admin/chargen"); + redirect("/admin/chargen"); +} diff --git a/src/app/admin/chargen/page.tsx b/src/app/admin/chargen/page.tsx index f689402..6290c6d 100644 --- a/src/app/admin/chargen/page.tsx +++ b/src/app/admin/chargen/page.tsx @@ -1,17 +1,19 @@ import Link from "next/link"; import { prisma } from "@/lib/prisma"; import { BackLink } from "@/components/back-link"; +import { ConfirmForm } from "@/components/confirm-form"; import { formatDate } from "@/lib/format"; -import { createBatchAction } from "../actions"; +import { createBatchAction, deleteBatchAction } from "../actions"; import { getLocale } from "@/i18n/locale"; import { getDictionary } from "@/i18n/get-dictionary"; -export default async function AdminBatchesPage() { +export default async function AdminBatchesPage(props: PageProps<"/admin/chargen">) { const t = getDictionary(await getLocale()); + const { error } = await props.searchParams; const [batches, harvests] = await Promise.all([ prisma.batch.findMany({ orderBy: { fillDate: "desc" }, - include: { harvest: true }, + include: { harvest: true, _count: { select: { products: true, orderItems: true } } }, }), prisma.harvest.findMany({ orderBy: [{ year: "desc" }] }), ]); @@ -20,6 +22,9 @@ export default async function AdminBatchesPage() {

{t.adminBatchesPage.title}

+ {error ? ( +

{error}

+ ) : null}
@@ -44,10 +49,22 @@ export default async function AdminBatchesPage() { - ))} diff --git a/src/app/admin/ernten/page.tsx b/src/app/admin/ernten/page.tsx index de439d3..599715c 100644 --- a/src/app/admin/ernten/page.tsx +++ b/src/app/admin/ernten/page.tsx @@ -1,14 +1,20 @@ import Link from "next/link"; import { prisma } from "@/lib/prisma"; import { BackLink } from "@/components/back-link"; +import { ConfirmForm } from "@/components/confirm-form"; import { formatDate } from "@/lib/format"; -import { createHarvestAction, toggleHarvestPublishedAction, syncHarvestsFromApiCoreAction } from "../actions"; +import { + createHarvestAction, + toggleHarvestPublishedAction, + syncHarvestsFromApiCoreAction, + deleteHarvestAction, +} from "../actions"; import { getLocale } from "@/i18n/locale"; import { getDictionary } from "@/i18n/get-dictionary"; export default async function AdminHarvestsPage(props: PageProps<"/admin/ernten">) { const t = getDictionary(await getLocale()); - const { synced, updated, skipped, failed, syncError, batchSynced, batchSkipped, batchFailed, batchLoadError } = + const { synced, updated, skipped, failed, syncError, batchSynced, batchSkipped, batchFailed, batchLoadError, error } = await props.searchParams; const harvests = await prisma.harvest.findMany({ orderBy: [{ year: "desc" }, { harvestDate: "desc" }], @@ -20,6 +26,9 @@ export default async function AdminHarvestsPage(props: PageProps<"/admin/ernten"

{t.adminHarvestsPage.title}

+ {error ? ( +

{error}

+ ) : null} {synced !== undefined ? (

{t.adminHarvestsPage.syncResult(Number(synced), Number(updated ?? 0), Number(skipped ?? 0), Number(failed ?? 0))} @@ -82,10 +91,22 @@ export default async function AdminHarvestsPage(props: PageProps<"/admin/ernten"
{harvest.isPublished ? null : } - + {harvest._count.batches === 0 && harvest._count.products === 0 ? ( + + + + + ) : null} ))} diff --git a/src/i18n/dictionaries/de.ts b/src/i18n/dictionaries/de.ts index db17475..d524da6 100644 --- a/src/i18n/dictionaries/de.ts +++ b/src/i18n/dictionaries/de.ts @@ -821,6 +821,8 @@ export const de: Dictionary = { colTotalGlasses: "Gläser gesamt", colRemaining: "Verbleibend", edit: "Bearbeiten", + delete: "Löschen", + deleteConfirm: (code) => `Charge "${code}" wirklich löschen? Dies kann nicht rückgängig gemacht werden.`, noBatchesYet: "Noch keine Chargen angelegt.", newBatchHeading: "Neue Charge anlegen", codeLabel: "Chargen-Code", @@ -887,6 +889,8 @@ export const de: Dictionary = { publish: "Veröffentlichen", unpublish: "Verbergen", edit: "Bearbeiten", + delete: "Löschen", + deleteConfirm: (label) => `"${label}" wirklich löschen? Dies kann nicht rückgängig gemacht werden.`, noHarvestsYet: "Noch keine Ernten angelegt.", newHarvestHeading: "Neue Ernte anlegen", typeLabel: "Typ (z.B. Frühtracht)", diff --git a/src/i18n/dictionaries/en.ts b/src/i18n/dictionaries/en.ts index 53b9f4b..0afed06 100644 --- a/src/i18n/dictionaries/en.ts +++ b/src/i18n/dictionaries/en.ts @@ -821,6 +821,8 @@ export const en: Dictionary = { colTotalGlasses: "Total jars", colRemaining: "Remaining", edit: "Edit", + delete: "Delete", + deleteConfirm: (code) => `Really delete batch "${code}"? This cannot be undone.`, noBatchesYet: "No batches created yet.", newBatchHeading: "Create new batch", codeLabel: "Batch code", @@ -887,6 +889,8 @@ export const en: Dictionary = { publish: "Publish", unpublish: "Unpublish", edit: "Edit", + delete: "Delete", + deleteConfirm: (label) => `Really delete "${label}"? This cannot be undone.`, noHarvestsYet: "No harvests created yet.", newHarvestHeading: "Create new harvest", typeLabel: "Type (e.g. Spring flow)", diff --git a/src/i18n/dictionary-type.ts b/src/i18n/dictionary-type.ts index ff79fb7..f8508cf 100644 --- a/src/i18n/dictionary-type.ts +++ b/src/i18n/dictionary-type.ts @@ -523,6 +523,8 @@ export interface Dictionary { colTotalGlasses: string; colRemaining: string; edit: string; + delete: string; + deleteConfirm: (code: string) => string; noBatchesYet: string; newBatchHeading: string; codeLabel: string; @@ -589,6 +591,8 @@ export interface Dictionary { publish: string; unpublish: string; edit: string; + delete: string; + deleteConfirm: (label: string) => string; noHarvestsYet: string; newHarvestHeading: string; typeLabel: string;

{formatDate(batch.bestBeforeDate)} {batch.glassCount} {batch.remainingGlasses} - + + {t.adminBatchesPage.edit} + {batch._count.products === 0 && batch._count.orderItems === 0 ? ( + + + + + ) : null}