diff --git a/ApiCore-Backend/src/honey-batches/dto/update-honey-measurement.dto.ts b/ApiCore-Backend/src/honey-batches/dto/update-honey-measurement.dto.ts new file mode 100644 index 0000000..ec5ef53 --- /dev/null +++ b/ApiCore-Backend/src/honey-batches/dto/update-honey-measurement.dto.ts @@ -0,0 +1,11 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateHoneyMeasurementDto } from './create-honey-measurement.dto'; + +/** + * A water-content reading typed in wrong has to be correctable: it decides + * whether a batch may be sold as honey at all, so a wrong figure left standing + * is worse than one that was never recorded. + */ +export class UpdateHoneyMeasurementDto extends PartialType( + CreateHoneyMeasurementDto, +) {} diff --git a/ApiCore-Backend/src/honey-batches/honey-batches.controller.ts b/ApiCore-Backend/src/honey-batches/honey-batches.controller.ts index c886ad8..8b015db 100644 --- a/ApiCore-Backend/src/honey-batches/honey-batches.controller.ts +++ b/ApiCore-Backend/src/honey-batches/honey-batches.controller.ts @@ -17,6 +17,7 @@ import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import { CreateHoneyBatchDto } from './dto/create-honey-batch.dto'; import { UpdateHoneyBatchDto } from './dto/update-honey-batch.dto'; import { CreateHoneyMeasurementDto } from './dto/create-honey-measurement.dto'; +import { UpdateHoneyMeasurementDto } from './dto/update-honey-measurement.dto'; import { RelocateHoneyBatchDto } from './dto/relocate-honey-batch.dto'; import { SetHoneyBatchContainerAllocationDto } from './dto/set-honey-batch-container-allocation.dto'; import { HoneyBatchesService } from './honey-batches.service'; @@ -117,6 +118,20 @@ export class HoneyBatchesController { } @RequirePermission('honey-batches:measure') + @RequirePermission('honey-batches:measure') + @Patch('measurements/:measurementId') + updateMeasurement( + @Param('measurementId') measurementId: string, + @Body() dto: UpdateHoneyMeasurementDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.honeyBatchesService.updateMeasurement( + measurementId, + dto, + actor.id, + ); + } + @Post(':id/measurements') addMeasurement( @Param('id') id: string, diff --git a/ApiCore-Backend/src/honey-batches/honey-batches.service.spec.ts b/ApiCore-Backend/src/honey-batches/honey-batches.service.spec.ts index 35b03f6..972d73f 100644 --- a/ApiCore-Backend/src/honey-batches/honey-batches.service.spec.ts +++ b/ApiCore-Backend/src/honey-batches/honey-batches.service.spec.ts @@ -14,7 +14,11 @@ describe('HoneyBatchesService', () => { create: jest.Mock; update: jest.Mock; }; - honeyMeasurement: { create: jest.Mock }; + honeyMeasurement: { + create: jest.Mock; + findUnique: jest.Mock; + update: jest.Mock; + }; storageContainer: { findUnique: jest.Mock }; honeyBatchRelocation: { create: jest.Mock; findMany: jest.Mock }; honeyBatchContainerAllocation: { @@ -60,7 +64,11 @@ describe('HoneyBatchesService', () => { create: jest.fn(), update: jest.fn(), }, - honeyMeasurement: { create: jest.fn() }, + honeyMeasurement: { + create: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + }, storageContainer: { findUnique: jest.fn() }, honeyBatchRelocation: { create: jest.fn(), findMany: jest.fn() }, honeyBatchContainerAllocation: { @@ -398,4 +406,41 @@ describe('HoneyBatchesService', () => { expect(prisma.honeyBatchRelocation.create).not.toHaveBeenCalled(); }); }); + + it('updateMeasurement corrects a reading and logs before/after', async () => { + const before = { + id: 'measurement-1', + honeyBatchId: 'batch-1', + waterContent: 22, + }; + prisma.honeyMeasurement.findUnique.mockResolvedValue(before); + prisma.honeyMeasurement.update.mockResolvedValue({ + ...before, + waterContent: 17.5, + }); + prisma.honeyBatch.findUnique.mockResolvedValue(honeyBatch); + + await service.updateMeasurement( + 'measurement-1', + { waterContent: 17.5 }, + 'user-1', + ); + + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: 'HoneyMeasurement', + entityId: 'measurement-1', + action: 'UPDATE', + beforeData: before, + }), + ); + }); + + it('updateMeasurement throws NotFoundException for an unknown reading', async () => { + prisma.honeyMeasurement.findUnique.mockResolvedValue(null); + + await expect( + service.updateMeasurement('missing', { waterContent: 17.5 }, 'user-1'), + ).rejects.toBeInstanceOf(NotFoundException); + }); }); diff --git a/ApiCore-Backend/src/honey-batches/honey-batches.service.ts b/ApiCore-Backend/src/honey-batches/honey-batches.service.ts index 6b1449b..8b52fa5 100644 --- a/ApiCore-Backend/src/honey-batches/honey-batches.service.ts +++ b/ApiCore-Backend/src/honey-batches/honey-batches.service.ts @@ -11,6 +11,7 @@ import { HoneyBatchStatus } from '../../generated/prisma'; import { CreateHoneyBatchDto } from './dto/create-honey-batch.dto'; import { UpdateHoneyBatchDto } from './dto/update-honey-batch.dto'; import { CreateHoneyMeasurementDto } from './dto/create-honey-measurement.dto'; +import { UpdateHoneyMeasurementDto } from './dto/update-honey-measurement.dto'; import { RelocateHoneyBatchDto } from './dto/relocate-honey-batch.dto'; import { SetHoneyBatchContainerAllocationDto } from './dto/set-honey-batch-container-allocation.dto'; @@ -122,6 +123,35 @@ export class HoneyBatchesService { return updated; } + async updateMeasurement( + measurementId: string, + dto: UpdateHoneyMeasurementDto, + actingUserId: string, + ) { + const before = await this.prisma.honeyMeasurement.findUnique({ + where: { id: measurementId }, + }); + if (!before) { + throw new NotFoundException('Honey measurement not found'); + } + const measurement = await this.prisma.honeyMeasurement.update({ + where: { id: measurementId }, + data: { + ...dto, + measuredAt: dto.measuredAt ? new Date(dto.measuredAt) : undefined, + }, + }); + await this.auditLog.record({ + entityType: 'HoneyMeasurement', + entityId: measurementId, + action: 'UPDATE', + userId: actingUserId, + beforeData: before, + afterData: measurement, + }); + return this.findById(before.honeyBatchId); + } + async addMeasurement( id: string, dto: CreateHoneyMeasurementDto, diff --git a/ApiCore-Backend/src/queens/dto/update-queen-evaluation.dto.ts b/ApiCore-Backend/src/queens/dto/update-queen-evaluation.dto.ts new file mode 100644 index 0000000..f2dd41a --- /dev/null +++ b/ApiCore-Backend/src/queens/dto/update-queen-evaluation.dto.ts @@ -0,0 +1,10 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateQueenEvaluationDto } from './create-queen-evaluation.dto'; + +/** + * Breeding decisions are made from these scores, so a rating entered against + * the wrong queen or with a slipped digit has to be correctable. + */ +export class UpdateQueenEvaluationDto extends PartialType( + CreateQueenEvaluationDto, +) {} diff --git a/ApiCore-Backend/src/queens/queens.controller.ts b/ApiCore-Backend/src/queens/queens.controller.ts index d7bb437..5d66406 100644 --- a/ApiCore-Backend/src/queens/queens.controller.ts +++ b/ApiCore-Backend/src/queens/queens.controller.ts @@ -16,6 +16,7 @@ import { CreateQueenDto } from './dto/create-queen.dto'; import { UpdateQueenDto } from './dto/update-queen.dto'; import { AssignColonyDto } from './dto/assign-colony.dto'; import { CreateQueenEvaluationDto } from './dto/create-queen-evaluation.dto'; +import { UpdateQueenEvaluationDto } from './dto/update-queen-evaluation.dto'; import { QueensService } from './queens.service'; @ApiTags('queens') @@ -81,6 +82,16 @@ export class QueensController { } @RequirePermission('queens:evaluate') + @RequirePermission('queens:evaluate') + @Patch('evaluations/:evaluationId') + updateEvaluation( + @Param('evaluationId') evaluationId: string, + @Body() dto: UpdateQueenEvaluationDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.queensService.updateEvaluation(evaluationId, dto, actor.id); + } + @Post(':id/evaluations') addEvaluation( @Param('id') id: string, diff --git a/ApiCore-Backend/src/queens/queens.service.ts b/ApiCore-Backend/src/queens/queens.service.ts index 10ec9bb..180af18 100644 --- a/ApiCore-Backend/src/queens/queens.service.ts +++ b/ApiCore-Backend/src/queens/queens.service.ts @@ -13,6 +13,7 @@ import { CreateQueenDto } from './dto/create-queen.dto'; import { UpdateQueenDto } from './dto/update-queen.dto'; import { AssignColonyDto } from './dto/assign-colony.dto'; import { CreateQueenEvaluationDto } from './dto/create-queen-evaluation.dto'; +import { UpdateQueenEvaluationDto } from './dto/update-queen-evaluation.dto'; export interface QueenFilter { status?: QueenStatus; @@ -227,6 +228,32 @@ export class QueensService { }); } + async updateEvaluation( + evaluationId: string, + dto: UpdateQueenEvaluationDto, + actingUserId: string, + ) { + const before = await this.prisma.queenEvaluation.findUnique({ + where: { id: evaluationId }, + }); + if (!before) { + throw new NotFoundException('Queen evaluation not found'); + } + const evaluation = await this.prisma.queenEvaluation.update({ + where: { id: evaluationId }, + data: { ...dto, date: dto.date ? new Date(dto.date) : undefined }, + }); + await this.auditLog.record({ + entityType: 'QueenEvaluation', + entityId: evaluationId, + action: 'UPDATE', + userId: actingUserId, + beforeData: before, + afterData: evaluation, + }); + return evaluation; + } + async addEvaluation( id: string, dto: CreateQueenEvaluationDto, diff --git a/ApiCore-Frontend/src/app/(app)/honey-batches/[id]/edit-measurement-dialog.tsx b/ApiCore-Frontend/src/app/(app)/honey-batches/[id]/edit-measurement-dialog.tsx new file mode 100644 index 0000000..babeffa --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/honey-batches/[id]/edit-measurement-dialog.tsx @@ -0,0 +1,175 @@ +"use client"; + +import { FormEvent, useState } from "react"; +import { Pencil } from "lucide-react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { updateHoneyMeasurement } from "@/lib/honey-batches/api"; +import { HoneyMeasurement } from "@/lib/honey-batches/types"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; + +/** + * Corrects a water-content reading. The figure decides whether a batch may be + * sold as honey at all, so a slipped digit left standing is worse than a + * reading that was never recorded. + */ +function EditMeasurementForm({ + measurement, + honeyBatchId, + onSaved, +}: { + measurement: HoneyMeasurement; + honeyBatchId: string; + onSaved: () => void; +}) { + const queryClient = useQueryClient(); + const [waterContent, setWaterContent] = useState( + measurement.waterContent.toString(), + ); + const [measuredAt, setMeasuredAt] = useState( + measurement.measuredAt.slice(0, 10), + ); + const [device, setDevice] = useState(measurement.device ?? ""); + const [temperature, setTemperature] = useState( + measurement.temperature?.toString() ?? "", + ); + const [notes, setNotes] = useState(measurement.notes ?? ""); + const [error, setError] = useState(null); + + const mutation = useMutation({ + mutationFn: () => + updateHoneyMeasurement(measurement.id, { + waterContent: Number(waterContent), + measuredAt: new Date(measuredAt).toISOString(), + device: device || undefined, + temperature: temperature ? Number(temperature) : undefined, + notes: notes || undefined, + }), + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: ["honey-batches", honeyBatchId], + }); + onSaved(); + }, + onError: (mutationError: Error) => { + const message = + mutationError.message || "Messung konnte nicht gespeichert werden"; + setError(message); + toast.error(message); + }, + }); + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + setError(null); + mutation.mutate(); + } + + return ( +
+
+ + setWaterContent(event.target.value)} + /> +
+
+ + setMeasuredAt(event.target.value)} + /> +
+
+
+ + setDevice(event.target.value)} + /> +
+
+ + setTemperature(event.target.value)} + /> +
+
+
+ +