diff --git a/ApiCore-Backend/src/auth/permissions/permission-catalog.ts b/ApiCore-Backend/src/auth/permissions/permission-catalog.ts index 1e8501e..323ff2e 100644 --- a/ApiCore-Backend/src/auth/permissions/permission-catalog.ts +++ b/ApiCore-Backend/src/auth/permissions/permission-catalog.ts @@ -191,6 +191,7 @@ export const PERMISSION_CATALOG = [ ['read', 'Ansehen'], ['create', 'Anlegen'], ['update', 'Bearbeiten'], + ['delete', 'Löschen'], ], 1600, ), diff --git a/ApiCore-Backend/src/bottling/bottling.controller.ts b/ApiCore-Backend/src/bottling/bottling.controller.ts index 2bed8f6..d8e9a52 100644 --- a/ApiCore-Backend/src/bottling/bottling.controller.ts +++ b/ApiCore-Backend/src/bottling/bottling.controller.ts @@ -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); + } } diff --git a/ApiCore-Backend/src/bottling/bottling.service.spec.ts b/ApiCore-Backend/src/bottling/bottling.service.spec.ts index a6f68bb..88416bc 100644 --- a/ApiCore-Backend/src/bottling/bottling.service.spec.ts +++ b/ApiCore-Backend/src/bottling/bottling.service.spec.ts @@ -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, + ); + }); }); diff --git a/ApiCore-Backend/src/bottling/bottling.service.ts b/ApiCore-Backend/src/bottling/bottling.service.ts index 7684fb7..770e460 100644 --- a/ApiCore-Backend/src/bottling/bottling.service.ts +++ b/ApiCore-Backend/src/bottling/bottling.service.ts @@ -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 }; + } } diff --git a/ApiCore-Backend/src/bottling/dto/update-bottling-batch.dto.ts b/ApiCore-Backend/src/bottling/dto/update-bottling-batch.dto.ts index c6a4c6a..01ee348 100644 --- a/ApiCore-Backend/src/bottling/dto/update-bottling-batch.dto.ts +++ b/ApiCore-Backend/src/bottling/dto/update-bottling-batch.dto.ts @@ -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; +} diff --git a/ApiCore-Frontend/src/app/(app)/bottling/[id]/bottling-delete-button.tsx b/ApiCore-Frontend/src/app/(app)/bottling/[id]/bottling-delete-button.tsx new file mode 100644 index 0000000..640f89d --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/bottling/[id]/bottling-delete-button.tsx @@ -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/ 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 ( + mutation.mutate()} + isPending={mutation.isPending} + /> + ); +} diff --git a/ApiCore-Frontend/src/app/(app)/bottling/[id]/bottling-edit-dialog.tsx b/ApiCore-Frontend/src/app/(app)/bottling/[id]/bottling-edit-dialog.tsx index df4c8cb..470bffd 100644 --- a/ApiCore-Frontend/src/app/(app)/bottling/[id]/bottling-edit-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/bottling/[id]/bottling-edit-dialog.tsx @@ -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) { - event.preventDefault(); - mutation.mutate(); - } return ( @@ -76,54 +54,205 @@ export function BottlingEditDialog({ bottlingBatch }: { bottlingBatch: BottlingB Abfüllung bearbeiten -
-
- - setDate(event.target.value)} - /> -
-
- - setBestBeforeDate(event.target.value)} - /> -
-
- - -
-
- - setLotNumber(event.target.value)} - /> -
- -
+ {/* Mounted only while the dialog is open, so every reopen starts from + the saved values instead of what a cancelled edit left behind. */} + {open ? ( + setOpen(false)} + /> + ) : null}
); } + +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(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) { + event.preventDefault(); + setError(null); + mutation.mutate(); + } + + return ( +
+
+ + +
+
+ + setDate(event.target.value)} + /> +
+
+ + setBestBeforeDate(event.target.value)} + /> +
+
+ + setJarSize(event.target.value)} + /> +
+
+ + setJarCount(event.target.value)} + /> +
+
+ + +
+
+ + setLotNumber(event.target.value)} + /> +
+ {error ? ( +

+ {error} +

+ ) : null} + +
+ ); +} diff --git a/ApiCore-Frontend/src/app/(app)/bottling/[id]/page.tsx b/ApiCore-Frontend/src/app/(app)/bottling/[id]/page.tsx index e4ddb38..a6bbf1c 100644 --- a/ApiCore-Frontend/src/app/(app)/bottling/[id]/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/bottling/[id]/page.tsx @@ -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() { Abfüllung {bottlingBatch.lotNumber}
- + {hasPermission("bottling:update") ? ( + + ) : null} + {hasPermission("bottling:delete") ? ( + + ) : null} { + return apiFetch<{ id: string }>(`/bottling-batches/${id}`, { + method: "DELETE", + }); +} diff --git a/ApiCore-Frontend/src/lib/bottling/types.ts b/ApiCore-Frontend/src/lib/bottling/types.ts index 66476af..f503cd3 100644 --- a/ApiCore-Frontend/src/lib/bottling/types.ts +++ b/ApiCore-Frontend/src/lib/bottling/types.ts @@ -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; }