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:
1 parent
dc13bd071b
commit
8f0f06fd13
7 files changed
+103
-6
No files matched your search
@@ -104,6 +104,55 @@ export async function createProductAction(formData: FormData): Promise<void> {
|
||||
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> {
|
||||
const admin = await requireAdmin();
|
||||
const id = str(formData, "id");
|
||||
|
||||
@@ -64,7 +64,10 @@ export default async function AdminBatchesPage(props: PageProps<"/admin/chargen"
|
||||
{t.adminBatchesPage.delete}
|
||||
</button>
|
||||
</ConfirmForm>
|
||||
) : null}
|
||||
) : (
|
||||
// Statt den Knopf wortlos wegzulassen: der Grund steht da.
|
||||
<span className="text-xs text-ink/50">{t.adminBatchesPage.notDeletable}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -106,7 +106,10 @@ export default async function AdminHarvestsPage(props: PageProps<"/admin/ernten"
|
||||
{t.adminHarvestsPage.delete}
|
||||
</button>
|
||||
</ConfirmForm>
|
||||
) : null}
|
||||
) : (
|
||||
// Statt den Knopf wortlos wegzulassen: der Grund steht da.
|
||||
<span className="text-xs text-ink/50">{t.adminHarvestsPage.notDeletable}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -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
|
||||
<span className="block text-xs text-honey-dark">{t.adminProductsPage.draftHint}</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<Link href={`/admin/produkte/${product.id}`} className="text-forest underline">
|
||||
<td className="p-4 whitespace-nowrap">
|
||||
<Link href={`/admin/produkte/${product.id}`} className="text-forest underline mr-4">
|
||||
{t.adminProductsPage.edit}
|
||||
</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>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
Reference in new issue
Block a user