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:
Nick SzaunigandClaude Opus 5 committed 2026-09-19 07:50:36 +02:00
1 parent 961ca04a40
commit 2b15795a60
10 files changed
+632 -109

No files matched your search

@@ -191,6 +191,7 @@ export const PERMISSION_CATALOG = [
['read', 'Ansehen'], ['read', 'Ansehen'],
['create', 'Anlegen'], ['create', 'Anlegen'],
['update', 'Bearbeiten'], ['update', 'Bearbeiten'],
['delete', 'Löschen'],
], ],
1600, 1600,
), ),
@@ -1,6 +1,7 @@
import { import {
Body, Body,
Controller, Controller,
Delete,
Get, Get,
Param, Param,
Patch, Patch,
@@ -51,4 +52,10 @@ export class BottlingController {
) { ) {
return this.bottlingService.update(id, dto, actor.id); return this.bottlingService.update(id, dto, actor.id);
} }
@RequirePermission('bottling:delete')
@Delete(':id')
remove(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
return this.bottlingService.remove(id, actor.id);
}
} }
@@ -20,13 +20,18 @@ describe('BottlingService', () => {
}; };
let auditLog: { record: jest.Mock }; let auditLog: { record: jest.Mock };
let tx: { let tx: {
bottlingBatch: { create: jest.Mock }; bottlingBatch: { create: jest.Mock; update: jest.Mock; delete: jest.Mock };
honeyBatch: { update: jest.Mock }; honeyBatch: { update: jest.Mock };
}; };
let webhooks: { dispatch: jest.Mock };
beforeEach(() => { beforeEach(() => {
tx = { tx = {
bottlingBatch: { create: jest.fn() }, bottlingBatch: {
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
honeyBatch: { update: jest.fn() }, honeyBatch: { update: jest.fn() },
}; };
prisma = { prisma = {
@@ -43,7 +48,7 @@ describe('BottlingService', () => {
), ),
}; };
auditLog = { record: jest.fn() }; auditLog = { record: jest.fn() };
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) }; webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
const automation = { generateNumber: jest.fn().mockResolvedValue(null) }; const automation = { generateNumber: jest.fn().mockResolvedValue(null) };
service = new BottlingService( service = new BottlingService(
prisma as unknown as PrismaService, prisma as unknown as PrismaService,
@@ -131,12 +136,19 @@ describe('BottlingService', () => {
}); });
it('update writes an audit log entry with before and after data', async () => { it('update writes an audit log entry with before and after data', async () => {
const before = { id: 'b1', lotNumber: 'L-1' }; const before = {
const updated = { id: 'b1', lotNumber: 'L-2' }; id: 'b1',
lotNumber: 'L-1',
honeyBatchId: 'h1',
honeyBatch: { id: 'h1', amount: 10 },
jarSize: 500,
jarCount: 10,
totalAmount: 5,
};
const updated = { ...before, lotNumber: 'L-2' };
prisma.bottlingBatch.findUnique prisma.bottlingBatch.findUnique
.mockResolvedValueOnce(before) .mockResolvedValueOnce(before)
.mockResolvedValueOnce(updated); .mockResolvedValueOnce(updated);
prisma.bottlingBatch.update.mockResolvedValue(updated);
const result = await service.update('b1', { lotNumber: 'L-2' }, 'user-1'); const result = await service.update('b1', { lotNumber: 'L-2' }, 'user-1');
@@ -160,4 +172,136 @@ describe('BottlingService', () => {
service.update('b1', { productId: 'missing' }, 'user-1'), service.update('b1', { productId: 'missing' }, 'user-1'),
).rejects.toBeInstanceOf(NotFoundException); ).rejects.toBeInstanceOf(NotFoundException);
}); });
it('clears the product assignment when productId is null', async () => {
const before = {
id: 'b1',
honeyBatchId: 'h1',
honeyBatch: { id: 'h1', amount: 10 },
jarSize: 500,
jarCount: 10,
totalAmount: 5,
productId: 'p1',
};
prisma.bottlingBatch.findUnique
.mockResolvedValueOnce(before)
.mockResolvedValueOnce({ ...before, productId: null });
await service.update('b1', { productId: null }, 'user-1');
expect(prisma.product.findUnique).not.toHaveBeenCalled();
const [updateArgs] = tx.bottlingBatch.update.mock.calls as [
[{ where: { id: string }; data: { productId: string | null } }],
];
expect(updateArgs[0].where).toEqual({ id: 'b1' });
expect(updateArgs[0].data.productId).toBeNull();
});
it('releases the previous amount before re-reserving a corrected jar count', async () => {
const before = {
id: 'b1',
honeyBatchId: 'h1',
honeyBatch: { id: 'h1', amount: 0 },
jarSize: 500,
jarCount: 20,
totalAmount: 10,
};
prisma.bottlingBatch.findUnique
.mockResolvedValueOnce(before)
.mockResolvedValueOnce({ ...before, jarCount: 10, totalAmount: 5 });
await service.update('b1', { jarCount: 10 }, 'user-1');
const [updateArgs] = tx.bottlingBatch.update.mock.calls as [
[{ data: { jarCount: number; totalAmount: number } }],
];
expect(updateArgs[0].data.jarCount).toBe(10);
expect(updateArgs[0].data.totalAmount).toBe(5);
expect(tx.honeyBatch.update).toHaveBeenCalledWith({
where: { id: 'h1' },
data: { amount: 5 },
});
});
it('rejects a corrected amount that exceeds the honey batch capacity', async () => {
prisma.bottlingBatch.findUnique.mockResolvedValueOnce({
id: 'b1',
honeyBatchId: 'h1',
honeyBatch: { id: 'h1', amount: 0 },
jarSize: 500,
jarCount: 10,
totalAmount: 5,
});
await expect(
service.update('b1', { jarCount: 40 }, 'user-1'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('moves the amount between honey batches when the honey batch changes', async () => {
const before = {
id: 'b1',
honeyBatchId: 'h1',
honeyBatch: { id: 'h1', amount: 2 },
jarSize: 500,
jarCount: 10,
totalAmount: 5,
};
prisma.bottlingBatch.findUnique
.mockResolvedValueOnce(before)
.mockResolvedValueOnce({ ...before, honeyBatchId: 'h2' });
prisma.honeyBatch.findUnique.mockResolvedValueOnce({ id: 'h2', amount: 8 });
await service.update('b1', { honeyBatchId: 'h2' }, 'user-1');
expect(tx.honeyBatch.update).toHaveBeenCalledWith({
where: { id: 'h1' },
data: { amount: 7 },
});
expect(tx.honeyBatch.update).toHaveBeenCalledWith({
where: { id: 'h2' },
data: { amount: 3, status: 'BOTTLED' },
});
});
it('remove gives the amount back and records a DELETE audit entry', async () => {
const before = {
id: 'b1',
honeyBatchId: 'h1',
honeyBatch: { id: 'h1', amount: 2 },
totalAmount: 5,
};
prisma.bottlingBatch.findUnique.mockResolvedValueOnce(before);
const result = await service.remove('b1', 'user-1');
expect(result).toEqual({ id: 'b1' });
expect(tx.bottlingBatch.delete).toHaveBeenCalledWith({
where: { id: 'b1' },
});
expect(tx.honeyBatch.update).toHaveBeenCalledWith({
where: { id: 'h1' },
data: { amount: 7 },
});
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'BottlingBatch',
entityId: 'b1',
action: 'DELETE',
beforeData: before,
}),
);
expect(webhooks.dispatch).toHaveBeenCalledWith(
'batch.updated',
before.honeyBatch,
);
});
it('remove throws NotFoundException for an unknown bottling batch', async () => {
prisma.bottlingBatch.findUnique.mockResolvedValueOnce(null);
await expect(service.remove('missing', 'user-1')).rejects.toBeInstanceOf(
NotFoundException,
);
});
}); });
@@ -20,6 +20,15 @@ export interface BottlingBatchFilter {
honeyBatchId?: string; honeyBatchId?: string;
} }
/**
* Amounts are kept in kilograms with gram precision. Rounding on every write
* keeps a honey batch's remaining amount from drifting when a bottling batch
* is corrected or removed and its amount is given back.
*/
function roundKg(amount: number): number {
return Math.round(amount * 1000) / 1000;
}
@Injectable() @Injectable()
export class BottlingService { export class BottlingService {
constructor( constructor(
@@ -122,8 +131,16 @@ export class BottlingService {
return created; return created;
} }
/**
* Corrects any field of an existing bottling batch. Changing the honey
* batch, the jar size or the jar count also rebalances the remaining amount
* of the affected honey batch(es): the previously bottled amount is given
* back before the corrected amount is taken again, so a correction never
* double-charges the source batch.
*/
async update(id: string, dto: UpdateBottlingBatchDto, actingUserId: string) { async update(id: string, dto: UpdateBottlingBatchDto, actingUserId: string) {
const before = await this.findById(id); const before = await this.findById(id);
if (dto.productId) { if (dto.productId) {
const product = await this.prisma.product.findUnique({ const product = await this.prisma.product.findUnique({
where: { id: dto.productId }, where: { id: dto.productId },
@@ -132,16 +149,87 @@ export class BottlingService {
throw new NotFoundException('Product not found'); throw new NotFoundException('Product not found');
} }
} }
await this.prisma.bottlingBatch.update({
where: { id }, const honeyBatchId = dto.honeyBatchId ?? before.honeyBatchId;
data: { const movedToOtherHoneyBatch = honeyBatchId !== before.honeyBatchId;
...dto, const jarSize = dto.jarSize ?? before.jarSize;
date: dto.date ? new Date(dto.date) : undefined, const jarCount = dto.jarCount ?? before.jarCount;
bestBeforeDate: dto.bestBeforeDate const totalAmount = roundKg((jarSize * jarCount) / 1000);
? new Date(dto.bestBeforeDate)
: undefined, const targetHoneyBatch = movedToOtherHoneyBatch
}, ? await this.prisma.honeyBatch.findUnique({ where: { id: honeyBatchId } })
: before.honeyBatch;
if (!targetHoneyBatch) {
throw new NotFoundException('Honey batch not found');
}
// Capacity on the target batch, with this bottling batch's current amount
// released first when it already sits on that same batch.
const availableOnTarget =
targetHoneyBatch.amount === null
? null
: roundKg(
targetHoneyBatch.amount +
(movedToOtherHoneyBatch ? 0 : before.totalAmount),
);
if (availableOnTarget !== null && totalAmount > availableOnTarget) {
throw new BadRequestException(
'Abfüllmenge übersteigt die verfügbare Menge der Honigcharge',
);
}
// A duplicate lotNumber surfaces as Prisma P2002 and is turned into a
// 409 with a field-specific message by AllExceptionsFilter.
await this.prisma.$transaction(async (tx) => {
await tx.bottlingBatch.update({
where: { id },
data: {
honeyBatchId,
productId: dto.productId === undefined ? undefined : dto.productId,
date: dto.date ? new Date(dto.date) : undefined,
jarSize,
jarCount,
totalAmount,
bestBeforeDate: dto.bestBeforeDate
? new Date(dto.bestBeforeDate)
: undefined,
lotNumber: dto.lotNumber,
},
});
if (movedToOtherHoneyBatch) {
await tx.honeyBatch.update({
where: { id: before.honeyBatchId },
data: {
amount:
before.honeyBatch.amount === null
? null
: roundKg(before.honeyBatch.amount + before.totalAmount),
},
});
await tx.honeyBatch.update({
where: { id: honeyBatchId },
data: {
amount:
availableOnTarget === null
? null
: roundKg(availableOnTarget - totalAmount),
status: 'BOTTLED',
},
});
} else if (totalAmount !== before.totalAmount) {
await tx.honeyBatch.update({
where: { id: honeyBatchId },
data: {
amount:
availableOnTarget === null
? null
: roundKg(availableOnTarget - totalAmount),
},
});
}
}); });
const bottlingBatch = await this.findById(id); const bottlingBatch = await this.findById(id);
await this.auditLog.record({ await this.auditLog.record({
entityType: 'BottlingBatch', entityType: 'BottlingBatch',
@@ -151,6 +239,42 @@ export class BottlingService {
beforeData: before, beforeData: before,
afterData: bottlingBatch, afterData: bottlingBatch,
}); });
await this.webhooks.dispatch('batch.updated', bottlingBatch.honeyBatch);
return bottlingBatch; return bottlingBatch;
} }
/**
* Removes a bottling batch that was entered by mistake and gives its amount
* back to the honey batch. The full record is kept in the audit log
* (action DELETE, beforeData), so the history survives the row.
*
* The honey batch's status is deliberately left untouched: the status it
* held before being bottled is not recoverable here, and guessing it would
* write a fact that never happened.
*/
async remove(id: string, actingUserId: string) {
const before = await this.findById(id);
await this.prisma.$transaction(async (tx) => {
await tx.bottlingBatch.delete({ where: { id } });
if (before.honeyBatch.amount !== null) {
await tx.honeyBatch.update({
where: { id: before.honeyBatchId },
data: {
amount: roundKg(before.honeyBatch.amount + before.totalAmount),
},
});
}
});
await this.auditLog.record({
entityType: 'BottlingBatch',
entityId: id,
action: 'DELETE',
userId: actingUserId,
beforeData: before,
});
await this.webhooks.dispatch('batch.updated', before.honeyBatch);
return { id };
}
} }
@@ -1,11 +1,63 @@
import { PartialType, PickType } from '@nestjs/swagger'; import { ApiPropertyOptional } from '@nestjs/swagger';
import { CreateBottlingBatchDto } from './create-bottling-batch.dto'; import {
IsDateString,
IsInt,
IsNumber,
IsOptional,
IsPositive,
IsString,
ValidateIf,
} from 'class-validator';
export class UpdateBottlingBatchDto extends PartialType( /**
PickType(CreateBottlingBatchDto, [ * Every field of a bottling batch stays correctable after the fact - a
'productId', * mistyped jar count or a batch bottled against the wrong honey batch is a
'date', * data-entry error, not a fact of production. The service rebalances the
'bestBeforeDate', * affected honey batch amounts for any change to honeyBatchId/jarSize/
'lotNumber', * jarCount, so the traceability chain stays consistent.
] as const), *
) {} * `productId: null` explicitly clears the product assignment; omitting the
* field leaves it untouched.
*/
export class UpdateBottlingBatchDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
honeyBatchId?: string;
@ApiPropertyOptional({
nullable: true,
description: 'null entfernt die Produktzuordnung',
})
@IsOptional()
@ValidateIf((_object, value) => value !== null)
@IsString()
productId?: string | null;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
date?: string;
@ApiPropertyOptional({ description: 'Glasgröße in Gramm' })
@IsOptional()
@IsNumber()
@IsPositive()
jarSize?: number;
@ApiPropertyOptional()
@IsOptional()
@IsInt()
@IsPositive()
jarCount?: number;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
bestBeforeDate?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
lotNumber?: string;
}
@@ -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 { toast } from "sonner";
import { updateBottlingBatch } from "@/lib/bottling/api"; import { updateBottlingBatch } from "@/lib/bottling/api";
import { BottlingBatch } from "@/lib/bottling/types"; import { BottlingBatch } from "@/lib/bottling/types";
import { fetchHoneyBatches } from "@/lib/honey-batches/api";
import { fetchProducts } from "@/lib/products/api"; import { fetchProducts } from "@/lib/products/api";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -25,42 +26,19 @@ import {
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } 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 [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 ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
@@ -76,54 +54,205 @@ export function BottlingEditDialog({ bottlingBatch }: { bottlingBatch: BottlingB
<DialogHeader> <DialogHeader>
<DialogTitle>Abfüllung bearbeiten</DialogTitle> <DialogTitle>Abfüllung bearbeiten</DialogTitle>
</DialogHeader> </DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}> {/* Mounted only while the dialog is open, so every reopen starts from
<div className="flex flex-col gap-2"> the saved values instead of what a cancelled edit left behind. */}
<Label htmlFor="bottling-edit-date">Datum</Label> {open ? (
<Input <BottlingEditForm
id="bottling-edit-date" bottlingBatch={bottlingBatch}
type="date" onSaved={() => setOpen(false)}
value={date} />
onChange={(event) => setDate(event.target.value)} ) : null}
/>
</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>
</DialogContent> </DialogContent>
</Dialog> </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 { fetchBottlingBatch } from "@/lib/bottling/api";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { QrCodeDialog } from "@/components/qr-code-dialog"; import { QrCodeDialog } from "@/components/qr-code-dialog";
import { useAuth } from "@/lib/auth/auth-context";
import { BottlingEditDialog } from "./bottling-edit-dialog"; import { BottlingEditDialog } from "./bottling-edit-dialog";
import { BottlingDeleteButton } from "./bottling-delete-button";
import { PrintLabelsCard } from "./print-labels-card"; import { PrintLabelsCard } from "./print-labels-card";
export default function BottlingBatchDetailPage() { export default function BottlingBatchDetailPage() {
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
const { hasPermission } = useAuth();
const { data: bottlingBatch, isLoading, isError } = useQuery({ const { data: bottlingBatch, isLoading, isError } = useQuery({
queryKey: ["bottling-batches", params.id], queryKey: ["bottling-batches", params.id],
queryFn: () => fetchBottlingBatch(params.id), queryFn: () => fetchBottlingBatch(params.id),
@@ -43,7 +46,12 @@ export default function BottlingBatchDetailPage() {
<CardTitle className="font-mono">Abfüllung {bottlingBatch.lotNumber}</CardTitle> <CardTitle className="font-mono">Abfüllung {bottlingBatch.lotNumber}</CardTitle>
</div> </div>
<div className="flex items-center gap-2"> <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 <QrCodeDialog
entityType="bottling-batches" entityType="bottling-batches"
id={bottlingBatch.id} id={bottlingBatch.id}
+6
View File
@@ -37,3 +37,9 @@ export function updateBottlingBatch(
body: JSON.stringify(input), body: JSON.stringify(input),
}); });
} }
export function deleteBottlingBatch(id: string): Promise<{ id: string }> {
return apiFetch<{ id: string }>(`/bottling-batches/${id}`, {
method: "DELETE",
});
}
+5 -1
View File
@@ -24,8 +24,12 @@ export interface CreateBottlingBatchInput {
} }
export interface UpdateBottlingBatchInput { export interface UpdateBottlingBatchInput {
productId?: string; honeyBatchId?: string;
/** `null` removes the product assignment, `undefined` leaves it untouched. */
productId?: string | null;
date?: string; date?: string;
jarSize?: number;
jarCount?: number;
bestBeforeDate?: string; bestBeforeDate?: string;
lotNumber?: string; lotNumber?: string;
} }