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
@@ -191,6 +191,7 @@ export const PERMISSION_CATALOG = [
|
||||
['read', 'Ansehen'],
|
||||
['create', 'Anlegen'],
|
||||
['update', 'Bearbeiten'],
|
||||
['delete', 'Löschen'],
|
||||
],
|
||||
1600,
|
||||
),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
@@ -51,4 +52,10 @@ export class BottlingController {
|
||||
) {
|
||||
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 tx: {
|
||||
bottlingBatch: { create: jest.Mock };
|
||||
bottlingBatch: { create: jest.Mock; update: jest.Mock; delete: jest.Mock };
|
||||
honeyBatch: { update: jest.Mock };
|
||||
};
|
||||
let webhooks: { dispatch: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
tx = {
|
||||
bottlingBatch: { create: jest.fn() },
|
||||
bottlingBatch: {
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
honeyBatch: { update: jest.fn() },
|
||||
};
|
||||
prisma = {
|
||||
@@ -43,7 +48,7 @@ describe('BottlingService', () => {
|
||||
),
|
||||
};
|
||||
auditLog = { record: jest.fn() };
|
||||
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
|
||||
webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
|
||||
const automation = { generateNumber: jest.fn().mockResolvedValue(null) };
|
||||
service = new BottlingService(
|
||||
prisma as unknown as PrismaService,
|
||||
@@ -131,12 +136,19 @@ describe('BottlingService', () => {
|
||||
});
|
||||
|
||||
it('update writes an audit log entry with before and after data', async () => {
|
||||
const before = { id: 'b1', lotNumber: 'L-1' };
|
||||
const updated = { id: 'b1', lotNumber: 'L-2' };
|
||||
const before = {
|
||||
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
|
||||
.mockResolvedValueOnce(before)
|
||||
.mockResolvedValueOnce(updated);
|
||||
prisma.bottlingBatch.update.mockResolvedValue(updated);
|
||||
|
||||
const result = await service.update('b1', { lotNumber: 'L-2' }, 'user-1');
|
||||
|
||||
@@ -160,4 +172,136 @@ describe('BottlingService', () => {
|
||||
service.update('b1', { productId: 'missing' }, 'user-1'),
|
||||
).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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()
|
||||
export class BottlingService {
|
||||
constructor(
|
||||
@@ -122,8 +131,16 @@ export class BottlingService {
|
||||
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) {
|
||||
const before = await this.findById(id);
|
||||
|
||||
if (dto.productId) {
|
||||
const product = await this.prisma.product.findUnique({
|
||||
where: { id: dto.productId },
|
||||
@@ -132,16 +149,87 @@ export class BottlingService {
|
||||
throw new NotFoundException('Product not found');
|
||||
}
|
||||
}
|
||||
await this.prisma.bottlingBatch.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...dto,
|
||||
date: dto.date ? new Date(dto.date) : undefined,
|
||||
bestBeforeDate: dto.bestBeforeDate
|
||||
? new Date(dto.bestBeforeDate)
|
||||
: undefined,
|
||||
},
|
||||
|
||||
const honeyBatchId = dto.honeyBatchId ?? before.honeyBatchId;
|
||||
const movedToOtherHoneyBatch = honeyBatchId !== before.honeyBatchId;
|
||||
const jarSize = dto.jarSize ?? before.jarSize;
|
||||
const jarCount = dto.jarCount ?? before.jarCount;
|
||||
const totalAmount = roundKg((jarSize * jarCount) / 1000);
|
||||
|
||||
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);
|
||||
await this.auditLog.record({
|
||||
entityType: 'BottlingBatch',
|
||||
@@ -151,6 +239,42 @@ export class BottlingService {
|
||||
beforeData: before,
|
||||
afterData: bottlingBatch,
|
||||
});
|
||||
await this.webhooks.dispatch('batch.updated', bottlingBatch.honeyBatch);
|
||||
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 { CreateBottlingBatchDto } from './create-bottling-batch.dto';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsDateString,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsPositive,
|
||||
IsString,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateBottlingBatchDto extends PartialType(
|
||||
PickType(CreateBottlingBatchDto, [
|
||||
'productId',
|
||||
'date',
|
||||
'bestBeforeDate',
|
||||
'lotNumber',
|
||||
] as const),
|
||||
) {}
|
||||
/**
|
||||
* Every field of a bottling batch stays correctable after the fact - a
|
||||
* mistyped jar count or a batch bottled against the wrong honey batch is a
|
||||
* data-entry error, not a fact of production. The service rebalances the
|
||||
* affected honey batch amounts for any change to honeyBatchId/jarSize/
|
||||
* jarCount, so the traceability chain stays consistent.
|
||||
*
|
||||
* `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 { 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