feat: make every field of a bottling batch correctable and deletable
A bottling batch could only be corrected in four of its seven fields after creation: honey batch, jar size and jar count were frozen at creation time, an assigned product could not be cleared again, and there was no way to remove a record entered by mistake at all. A mistyped jar count is a data-entry error, not a fact of production, so it has to stay correctable. Backend: - UpdateBottlingBatchDto now covers every field. `productId: null` explicitly clears the product assignment; omitting it leaves it alone. - BottlingService.update rebalances the affected honey batch amounts: the previously bottled amount is given back before the corrected amount is taken again, so a correction never double-charges the source batch. Moving a batch to a different honey batch credits the old one and debits the new one in the same transaction. - New DELETE /bottling-batches/:id gives the bottled amount back to the honey batch and keeps the full record in the audit log (action DELETE, beforeData). The honey batch status is deliberately left untouched - the status it held before being bottled is not recoverable here. - New permission bottling:delete, checked server-side. The catalog sync grants it to system roles on the next boot. Frontend: - The edit dialog exposes all fields, offers "Kein Produkt" to clear the assignment, keeps a discontinued product selectable so opening the dialog cannot silently drop it, and shows backend errors inline. - The form is mounted only while the dialog is open, so a cancelled edit never leaks its values into the next one. - New delete button behind a confirmation that names the amount credited back and warns that the public origin page for the lot number stops resolving. - Edit and delete are additionally hidden without the matching permission. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
1 parent
961ca04a40
commit
2b15795a60
10 files changed
+632
-109
No files matched your search
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { deleteBottlingBatch } from "@/lib/bottling/api";
|
||||
import { BottlingBatch } from "@/lib/bottling/types";
|
||||
import { ConfirmDialog } from "@/components/confirm-dialog";
|
||||
|
||||
/**
|
||||
* Deleting a bottling batch is the correction path for a mis-entered record:
|
||||
* the bottled amount goes back to the honey batch and the full row is kept in
|
||||
* the audit log. It also retires the lot number, so the public origin page
|
||||
* /herkunft/<lotNumber> stops resolving - hence the explicit confirmation.
|
||||
*/
|
||||
export function BottlingDeleteButton({
|
||||
bottlingBatch,
|
||||
}: {
|
||||
bottlingBatch: BottlingBatch;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => deleteBottlingBatch(bottlingBatch.id),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["bottling-batches"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["honey-batches"] });
|
||||
toast.success("Abfüllung gelöscht");
|
||||
router.push("/bottling");
|
||||
},
|
||||
onError: (error: Error) =>
|
||||
toast.error(error.message || "Abfüllung konnte nicht gelöscht werden"),
|
||||
});
|
||||
|
||||
return (
|
||||
<ConfirmDialog
|
||||
triggerLabel="Löschen"
|
||||
triggerVariant="outline"
|
||||
triggerSize="sm"
|
||||
title="Abfüllung löschen"
|
||||
description={`Abfüllung ${bottlingBatch.lotNumber} wird entfernt und ${bottlingBatch.totalAmount} kg werden der Honigcharge ${bottlingBatch.honeyBatch.batchNumber} wieder gutgeschrieben. Die öffentliche Herkunftsseite zu dieser Los-/Chargennummer ist danach nicht mehr erreichbar.`}
|
||||
confirmLabel="Endgültig löschen"
|
||||
onConfirm={() => mutation.mutate()}
|
||||
isPending={mutation.isPending}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { updateBottlingBatch } from "@/lib/bottling/api";
|
||||
import { BottlingBatch } from "@/lib/bottling/types";
|
||||
import { fetchHoneyBatches } from "@/lib/honey-batches/api";
|
||||
import { fetchProducts } from "@/lib/products/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -25,42 +26,19 @@ import {
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
export function BottlingEditDialog({ bottlingBatch }: { bottlingBatch: BottlingBatch }) {
|
||||
const queryClient = useQueryClient();
|
||||
/**
|
||||
* Sentinel for "no product": the select needs a selectable item to clear the
|
||||
* assignment again, and an empty string would be read as "nothing chosen yet"
|
||||
* and render the placeholder instead.
|
||||
*/
|
||||
const NO_PRODUCT = "__none__";
|
||||
|
||||
export function BottlingEditDialog({
|
||||
bottlingBatch,
|
||||
}: {
|
||||
bottlingBatch: BottlingBatch;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [date, setDate] = useState(bottlingBatch.date.slice(0, 10));
|
||||
const [bestBeforeDate, setBestBeforeDate] = useState(
|
||||
bottlingBatch.bestBeforeDate.slice(0, 10),
|
||||
);
|
||||
const [lotNumber, setLotNumber] = useState(bottlingBatch.lotNumber);
|
||||
const [productId, setProductId] = useState(bottlingBatch.productId ?? "");
|
||||
|
||||
const { data: products } = useQuery({
|
||||
queryKey: ["products", { status: "ACTIVE" }],
|
||||
queryFn: () => fetchProducts({ status: "ACTIVE" }),
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
updateBottlingBatch(bottlingBatch.id, {
|
||||
date: new Date(date).toISOString(),
|
||||
bestBeforeDate: new Date(bestBeforeDate).toISOString(),
|
||||
lotNumber,
|
||||
productId: productId || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["bottling-batches", bottlingBatch.id],
|
||||
});
|
||||
setOpen(false);
|
||||
},
|
||||
onError: () => toast.error("Abfüllung konnte nicht gespeichert werden"),
|
||||
});
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
mutation.mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
@@ -76,54 +54,205 @@ export function BottlingEditDialog({ bottlingBatch }: { bottlingBatch: BottlingB
|
||||
<DialogHeader>
|
||||
<DialogTitle>Abfüllung bearbeiten</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="bottling-edit-date">Datum</Label>
|
||||
<Input
|
||||
id="bottling-edit-date"
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(event) => setDate(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="bottling-edit-best-before">Mindesthaltbarkeitsdatum</Label>
|
||||
<Input
|
||||
id="bottling-edit-best-before"
|
||||
type="date"
|
||||
value={bestBeforeDate}
|
||||
onChange={(event) => setBestBeforeDate(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="bottling-edit-product">Produkt</Label>
|
||||
<Select value={productId} onValueChange={(value) => setProductId(value ?? "")}>
|
||||
<SelectTrigger id="bottling-edit-product">
|
||||
<SelectValue placeholder="Produkt wählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{products?.map((product) => (
|
||||
<SelectItem key={product.id} value={product.id}>
|
||||
{product.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="bottling-edit-lot">Los-/Chargennummer</Label>
|
||||
<Input
|
||||
id="bottling-edit-lot"
|
||||
required
|
||||
value={lotNumber}
|
||||
onChange={(event) => setLotNumber(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Speichert…" : "Speichern"}
|
||||
</Button>
|
||||
</form>
|
||||
{/* Mounted only while the dialog is open, so every reopen starts from
|
||||
the saved values instead of what a cancelled edit left behind. */}
|
||||
{open ? (
|
||||
<BottlingEditForm
|
||||
bottlingBatch={bottlingBatch}
|
||||
onSaved={() => setOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function BottlingEditForm({
|
||||
bottlingBatch,
|
||||
onSaved,
|
||||
}: {
|
||||
bottlingBatch: BottlingBatch;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [honeyBatchId, setHoneyBatchId] = useState(bottlingBatch.honeyBatchId);
|
||||
const [date, setDate] = useState(bottlingBatch.date.slice(0, 10));
|
||||
const [bestBeforeDate, setBestBeforeDate] = useState(
|
||||
bottlingBatch.bestBeforeDate.slice(0, 10),
|
||||
);
|
||||
const [jarSize, setJarSize] = useState(String(bottlingBatch.jarSize));
|
||||
const [jarCount, setJarCount] = useState(String(bottlingBatch.jarCount));
|
||||
const [lotNumber, setLotNumber] = useState(bottlingBatch.lotNumber);
|
||||
const [productId, setProductId] = useState(
|
||||
bottlingBatch.productId ?? NO_PRODUCT,
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { data: honeyBatches } = useQuery({
|
||||
queryKey: ["honey-batches"],
|
||||
queryFn: () => fetchHoneyBatches(),
|
||||
});
|
||||
const { data: products } = useQuery({
|
||||
queryKey: ["products", { status: "ACTIVE" }],
|
||||
queryFn: () => fetchProducts({ status: "ACTIVE" }),
|
||||
});
|
||||
|
||||
// The assigned product may have been discontinued since bottling - keep it
|
||||
// selectable so opening the dialog does not silently drop the assignment.
|
||||
const productOptions = [
|
||||
...(products ?? []).map((product) => ({
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
})),
|
||||
...(bottlingBatch.product &&
|
||||
!(products ?? []).some(
|
||||
(product) => product.id === bottlingBatch.product?.id,
|
||||
)
|
||||
? [bottlingBatch.product]
|
||||
: []),
|
||||
];
|
||||
const honeyBatchOptions = [
|
||||
...(honeyBatches ?? []).map((honeyBatch) => ({
|
||||
id: honeyBatch.id,
|
||||
batchNumber: honeyBatch.batchNumber,
|
||||
})),
|
||||
...((honeyBatches ?? []).some(
|
||||
(honeyBatch) => honeyBatch.id === bottlingBatch.honeyBatchId,
|
||||
)
|
||||
? []
|
||||
: [bottlingBatch.honeyBatch]),
|
||||
];
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
updateBottlingBatch(bottlingBatch.id, {
|
||||
honeyBatchId,
|
||||
date: new Date(date).toISOString(),
|
||||
bestBeforeDate: new Date(bestBeforeDate).toISOString(),
|
||||
jarSize: Number(jarSize),
|
||||
jarCount: Number(jarCount),
|
||||
lotNumber,
|
||||
productId: productId === NO_PRODUCT ? null : productId,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["bottling-batches"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["honey-batches"] });
|
||||
onSaved();
|
||||
},
|
||||
onError: (mutationError: Error) => {
|
||||
const message =
|
||||
mutationError.message || "Abfüllung konnte nicht gespeichert werden";
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
mutation.mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="bottling-edit-honey-batch">Honigcharge</Label>
|
||||
<Select
|
||||
value={honeyBatchId}
|
||||
onValueChange={(value) => setHoneyBatchId(value ?? honeyBatchId)}
|
||||
>
|
||||
<SelectTrigger id="bottling-edit-honey-batch">
|
||||
<SelectValue placeholder="Charge wählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{honeyBatchOptions.map((honeyBatch) => (
|
||||
<SelectItem key={honeyBatch.id} value={honeyBatch.id}>
|
||||
{honeyBatch.batchNumber}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="bottling-edit-date">Datum</Label>
|
||||
<Input
|
||||
id="bottling-edit-date"
|
||||
type="date"
|
||||
required
|
||||
value={date}
|
||||
onChange={(event) => setDate(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="bottling-edit-best-before">
|
||||
Mindesthaltbarkeitsdatum
|
||||
</Label>
|
||||
<Input
|
||||
id="bottling-edit-best-before"
|
||||
type="date"
|
||||
required
|
||||
value={bestBeforeDate}
|
||||
onChange={(event) => setBestBeforeDate(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="bottling-edit-jar-size">Glasgröße (g)</Label>
|
||||
<Input
|
||||
id="bottling-edit-jar-size"
|
||||
type="number"
|
||||
min="1"
|
||||
required
|
||||
value={jarSize}
|
||||
onChange={(event) => setJarSize(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="bottling-edit-jar-count">Anzahl Gläser</Label>
|
||||
<Input
|
||||
id="bottling-edit-jar-count"
|
||||
type="number"
|
||||
min="1"
|
||||
required
|
||||
value={jarCount}
|
||||
onChange={(event) => setJarCount(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="bottling-edit-product">Produkt</Label>
|
||||
<Select
|
||||
value={productId}
|
||||
onValueChange={(value) => setProductId(value ?? NO_PRODUCT)}
|
||||
>
|
||||
<SelectTrigger id="bottling-edit-product">
|
||||
<SelectValue placeholder="Produkt wählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_PRODUCT}>Kein Produkt</SelectItem>
|
||||
{productOptions.map((product) => (
|
||||
<SelectItem key={product.id} value={product.id}>
|
||||
{product.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="bottling-edit-lot">Los-/Chargennummer</Label>
|
||||
<Input
|
||||
id="bottling-edit-lot"
|
||||
required
|
||||
value={lotNumber}
|
||||
onChange={(event) => setLotNumber(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Speichert…" : "Speichern"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -7,11 +7,14 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchBottlingBatch } from "@/lib/bottling/api";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { QrCodeDialog } from "@/components/qr-code-dialog";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { BottlingEditDialog } from "./bottling-edit-dialog";
|
||||
import { BottlingDeleteButton } from "./bottling-delete-button";
|
||||
import { PrintLabelsCard } from "./print-labels-card";
|
||||
|
||||
export default function BottlingBatchDetailPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const { hasPermission } = useAuth();
|
||||
const { data: bottlingBatch, isLoading, isError } = useQuery({
|
||||
queryKey: ["bottling-batches", params.id],
|
||||
queryFn: () => fetchBottlingBatch(params.id),
|
||||
@@ -43,7 +46,12 @@ export default function BottlingBatchDetailPage() {
|
||||
<CardTitle className="font-mono">Abfüllung {bottlingBatch.lotNumber}</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<BottlingEditDialog bottlingBatch={bottlingBatch} />
|
||||
{hasPermission("bottling:update") ? (
|
||||
<BottlingEditDialog bottlingBatch={bottlingBatch} />
|
||||
) : null}
|
||||
{hasPermission("bottling:delete") ? (
|
||||
<BottlingDeleteButton bottlingBatch={bottlingBatch} />
|
||||
) : null}
|
||||
<QrCodeDialog
|
||||
entityType="bottling-batches"
|
||||
id={bottlingBatch.id}
|
||||
|
||||
@@ -37,3 +37,9 @@ export function updateBottlingBatch(
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteBottlingBatch(id: string): Promise<{ id: string }> {
|
||||
return apiFetch<{ id: string }>(`/bottling-batches/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
@@ -24,8 +24,12 @@ export interface CreateBottlingBatchInput {
|
||||
}
|
||||
|
||||
export interface UpdateBottlingBatchInput {
|
||||
productId?: string;
|
||||
honeyBatchId?: string;
|
||||
/** `null` removes the product assignment, `undefined` leaves it untouched. */
|
||||
productId?: string | null;
|
||||
date?: string;
|
||||
jarSize?: number;
|
||||
jarCount?: number;
|
||||
bestBeforeDate?: string;
|
||||
lotNumber?: string;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user