feat: allow correcting honey measurements and queen evaluations
Both records could be created but never changed. A water-content reading decides whether a batch may be sold as honey at all, and queen ratings drive breeding decisions - a slipped digit in either is exactly the kind of wrong value that must not be left standing. - New PATCH /honey-batches/measurements/:measurementId behind honey-batches:measure, and PATCH /queens/evaluations/:evaluationId behind queens:evaluate. Both are addressed by the child record's own id, so the routes take two path segments and do not collide with PATCH /:id. - Both log before and after data, like every other correction. - The measurement list on the honey batch page and the rating list on the queen page gained an edit dialog; the rating dialog reuses the existing criteria list rather than restating it. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
1 parent
b0e05daa42
commit
42ceb33844
14 files changed
+493
-5
No files matched your search
@@ -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,
|
||||||
|
) {}
|
||||||
@@ -17,6 +17,7 @@ import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
|||||||
import { CreateHoneyBatchDto } from './dto/create-honey-batch.dto';
|
import { CreateHoneyBatchDto } from './dto/create-honey-batch.dto';
|
||||||
import { UpdateHoneyBatchDto } from './dto/update-honey-batch.dto';
|
import { UpdateHoneyBatchDto } from './dto/update-honey-batch.dto';
|
||||||
import { CreateHoneyMeasurementDto } from './dto/create-honey-measurement.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 { RelocateHoneyBatchDto } from './dto/relocate-honey-batch.dto';
|
||||||
import { SetHoneyBatchContainerAllocationDto } from './dto/set-honey-batch-container-allocation.dto';
|
import { SetHoneyBatchContainerAllocationDto } from './dto/set-honey-batch-container-allocation.dto';
|
||||||
import { HoneyBatchesService } from './honey-batches.service';
|
import { HoneyBatchesService } from './honey-batches.service';
|
||||||
@@ -117,6 +118,20 @@ export class HoneyBatchesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@RequirePermission('honey-batches:measure')
|
@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')
|
@Post(':id/measurements')
|
||||||
addMeasurement(
|
addMeasurement(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ describe('HoneyBatchesService', () => {
|
|||||||
create: jest.Mock;
|
create: jest.Mock;
|
||||||
update: jest.Mock;
|
update: jest.Mock;
|
||||||
};
|
};
|
||||||
honeyMeasurement: { create: jest.Mock };
|
honeyMeasurement: {
|
||||||
|
create: jest.Mock;
|
||||||
|
findUnique: jest.Mock;
|
||||||
|
update: jest.Mock;
|
||||||
|
};
|
||||||
storageContainer: { findUnique: jest.Mock };
|
storageContainer: { findUnique: jest.Mock };
|
||||||
honeyBatchRelocation: { create: jest.Mock; findMany: jest.Mock };
|
honeyBatchRelocation: { create: jest.Mock; findMany: jest.Mock };
|
||||||
honeyBatchContainerAllocation: {
|
honeyBatchContainerAllocation: {
|
||||||
@@ -60,7 +64,11 @@ describe('HoneyBatchesService', () => {
|
|||||||
create: jest.fn(),
|
create: jest.fn(),
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
},
|
},
|
||||||
honeyMeasurement: { create: jest.fn() },
|
honeyMeasurement: {
|
||||||
|
create: jest.fn(),
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
},
|
||||||
storageContainer: { findUnique: jest.fn() },
|
storageContainer: { findUnique: jest.fn() },
|
||||||
honeyBatchRelocation: { create: jest.fn(), findMany: jest.fn() },
|
honeyBatchRelocation: { create: jest.fn(), findMany: jest.fn() },
|
||||||
honeyBatchContainerAllocation: {
|
honeyBatchContainerAllocation: {
|
||||||
@@ -398,4 +406,41 @@ describe('HoneyBatchesService', () => {
|
|||||||
expect(prisma.honeyBatchRelocation.create).not.toHaveBeenCalled();
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -11,6 +11,7 @@ import { HoneyBatchStatus } from '../../generated/prisma';
|
|||||||
import { CreateHoneyBatchDto } from './dto/create-honey-batch.dto';
|
import { CreateHoneyBatchDto } from './dto/create-honey-batch.dto';
|
||||||
import { UpdateHoneyBatchDto } from './dto/update-honey-batch.dto';
|
import { UpdateHoneyBatchDto } from './dto/update-honey-batch.dto';
|
||||||
import { CreateHoneyMeasurementDto } from './dto/create-honey-measurement.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 { RelocateHoneyBatchDto } from './dto/relocate-honey-batch.dto';
|
||||||
import { SetHoneyBatchContainerAllocationDto } from './dto/set-honey-batch-container-allocation.dto';
|
import { SetHoneyBatchContainerAllocationDto } from './dto/set-honey-batch-container-allocation.dto';
|
||||||
|
|
||||||
@@ -122,6 +123,35 @@ export class HoneyBatchesService {
|
|||||||
return updated;
|
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(
|
async addMeasurement(
|
||||||
id: string,
|
id: string,
|
||||||
dto: CreateHoneyMeasurementDto,
|
dto: CreateHoneyMeasurementDto,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
) {}
|
||||||
@@ -16,6 +16,7 @@ import { CreateQueenDto } from './dto/create-queen.dto';
|
|||||||
import { UpdateQueenDto } from './dto/update-queen.dto';
|
import { UpdateQueenDto } from './dto/update-queen.dto';
|
||||||
import { AssignColonyDto } from './dto/assign-colony.dto';
|
import { AssignColonyDto } from './dto/assign-colony.dto';
|
||||||
import { CreateQueenEvaluationDto } from './dto/create-queen-evaluation.dto';
|
import { CreateQueenEvaluationDto } from './dto/create-queen-evaluation.dto';
|
||||||
|
import { UpdateQueenEvaluationDto } from './dto/update-queen-evaluation.dto';
|
||||||
import { QueensService } from './queens.service';
|
import { QueensService } from './queens.service';
|
||||||
|
|
||||||
@ApiTags('queens')
|
@ApiTags('queens')
|
||||||
@@ -81,6 +82,16 @@ export class QueensController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@RequirePermission('queens:evaluate')
|
@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')
|
@Post(':id/evaluations')
|
||||||
addEvaluation(
|
addEvaluation(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { CreateQueenDto } from './dto/create-queen.dto';
|
|||||||
import { UpdateQueenDto } from './dto/update-queen.dto';
|
import { UpdateQueenDto } from './dto/update-queen.dto';
|
||||||
import { AssignColonyDto } from './dto/assign-colony.dto';
|
import { AssignColonyDto } from './dto/assign-colony.dto';
|
||||||
import { CreateQueenEvaluationDto } from './dto/create-queen-evaluation.dto';
|
import { CreateQueenEvaluationDto } from './dto/create-queen-evaluation.dto';
|
||||||
|
import { UpdateQueenEvaluationDto } from './dto/update-queen-evaluation.dto';
|
||||||
|
|
||||||
export interface QueenFilter {
|
export interface QueenFilter {
|
||||||
status?: QueenStatus;
|
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(
|
async addEvaluation(
|
||||||
id: string,
|
id: string,
|
||||||
dto: CreateQueenEvaluationDto,
|
dto: CreateQueenEvaluationDto,
|
||||||
|
|||||||
@@ -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<string | null>(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<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="measurement-edit-water">Wassergehalt (%)</Label>
|
||||||
|
<Input
|
||||||
|
id="measurement-edit-water"
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
required
|
||||||
|
value={waterContent}
|
||||||
|
onChange={(event) => setWaterContent(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="measurement-edit-date">Gemessen am</Label>
|
||||||
|
<Input
|
||||||
|
id="measurement-edit-date"
|
||||||
|
type="date"
|
||||||
|
required
|
||||||
|
value={measuredAt}
|
||||||
|
onChange={(event) => setMeasuredAt(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="flex flex-1 flex-col gap-2">
|
||||||
|
<Label htmlFor="measurement-edit-device">Gerät</Label>
|
||||||
|
<Input
|
||||||
|
id="measurement-edit-device"
|
||||||
|
value={device}
|
||||||
|
onChange={(event) => setDevice(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 flex-col gap-2">
|
||||||
|
<Label htmlFor="measurement-edit-temp">Temperatur (°C)</Label>
|
||||||
|
<Input
|
||||||
|
id="measurement-edit-temp"
|
||||||
|
type="number"
|
||||||
|
value={temperature}
|
||||||
|
onChange={(event) => setTemperature(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="measurement-edit-notes">Notizen</Label>
|
||||||
|
<Textarea
|
||||||
|
id="measurement-edit-notes"
|
||||||
|
value={notes}
|
||||||
|
onChange={(event) => setNotes(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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditMeasurementDialog({
|
||||||
|
measurement,
|
||||||
|
honeyBatchId,
|
||||||
|
}: {
|
||||||
|
measurement: HoneyMeasurement;
|
||||||
|
honeyBatchId: string;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger
|
||||||
|
render={
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
<Pencil className="size-3.5" />
|
||||||
|
Bearbeiten
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Messung bearbeiten</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{open ? (
|
||||||
|
<EditMeasurementForm
|
||||||
|
measurement={measurement}
|
||||||
|
honeyBatchId={honeyBatchId}
|
||||||
|
onSaved={() => setOpen(false)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { AddMeasurementDialog } from "./add-measurement-dialog";
|
import { AddMeasurementDialog } from "./add-measurement-dialog";
|
||||||
|
import { EditMeasurementDialog } from "./edit-measurement-dialog";
|
||||||
import { ExtractionEditDialog } from "./extraction-edit-dialog";
|
import { ExtractionEditDialog } from "./extraction-edit-dialog";
|
||||||
import { HoneyBatchEditDialog } from "./honey-batch-edit-dialog";
|
import { HoneyBatchEditDialog } from "./honey-batch-edit-dialog";
|
||||||
|
|
||||||
@@ -106,6 +107,8 @@ export default function HoneyBatchDetailPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
{honeyBatch.measurements.map((measurement) => (
|
{honeyBatch.measurements.map((measurement) => (
|
||||||
<div key={measurement.id} className="rounded-md border p-3">
|
<div key={measurement.id} className="rounded-md border p-3">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
{new Date(measurement.measuredAt).toLocaleDateString("de-DE")} ·{" "}
|
{new Date(measurement.measuredAt).toLocaleDateString("de-DE")} ·{" "}
|
||||||
{measurement.waterContent}% Wassergehalt
|
{measurement.waterContent}% Wassergehalt
|
||||||
@@ -114,6 +117,12 @@ export default function HoneyBatchDetailPage() {
|
|||||||
<p className="text-muted-foreground">{measurement.device}</p>
|
<p className="text-muted-foreground">{measurement.device}</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
<EditMeasurementDialog
|
||||||
|
measurement={measurement}
|
||||||
|
honeyBatchId={honeyBatch.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { FormEvent, useState } from "react";
|
import { FormEvent, useState } from "react";
|
||||||
import { Star } from "lucide-react";
|
import { Pencil, Star } from "lucide-react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { addQueenEvaluation, fetchQueenEvaluations } from "@/lib/queens/api";
|
import {
|
||||||
import { CreateQueenEvaluationInput } from "@/lib/queens/types";
|
addQueenEvaluation,
|
||||||
|
fetchQueenEvaluations,
|
||||||
|
updateQueenEvaluation,
|
||||||
|
} from "@/lib/queens/api";
|
||||||
|
import { CreateQueenEvaluationInput, QueenEvaluation } from "@/lib/queens/types";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -32,6 +36,122 @@ function todayIsoDate(): string {
|
|||||||
return new Date().toISOString().slice(0, 10);
|
return new Date().toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Corrects a rating after the fact. Breeding decisions are made from these
|
||||||
|
* scores, so a slipped digit has to be fixable rather than left standing.
|
||||||
|
*/
|
||||||
|
function EditEvaluationDialog({
|
||||||
|
evaluation,
|
||||||
|
queenId,
|
||||||
|
}: {
|
||||||
|
evaluation: QueenEvaluation;
|
||||||
|
queenId: string;
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [date, setDate] = useState(evaluation.date.slice(0, 10));
|
||||||
|
const [scores, setScores] = useState<Record<string, string>>(
|
||||||
|
Object.fromEntries(
|
||||||
|
CRITERIA.map((criterion) => [
|
||||||
|
criterion.key,
|
||||||
|
String(evaluation[criterion.key]),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
updateQueenEvaluation(evaluation.id, {
|
||||||
|
date: new Date(date).toISOString(),
|
||||||
|
colonyStrength: Number(scores.colonyStrength),
|
||||||
|
gentleness: Number(scores.gentleness),
|
||||||
|
combBuild: Number(scores.combBuild),
|
||||||
|
swarmingTendency: Number(scores.swarmingTendency),
|
||||||
|
broodPattern: Number(scores.broodPattern),
|
||||||
|
honeyYield: Number(scores.honeyYield),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: ["queens", queenId, "evaluations"],
|
||||||
|
});
|
||||||
|
setOpen(false);
|
||||||
|
setError(null);
|
||||||
|
},
|
||||||
|
onError: (mutationError: Error) =>
|
||||||
|
setError(
|
||||||
|
mutationError.message || "Bewertung konnte nicht gespeichert werden.",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
mutation.mutate();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger
|
||||||
|
render={
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
<Pencil className="size-3.5" />
|
||||||
|
Bearbeiten
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Bewertung bearbeiten</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor={`evaluation-edit-date-${evaluation.id}`}>Datum</Label>
|
||||||
|
<Input
|
||||||
|
id={`evaluation-edit-date-${evaluation.id}`}
|
||||||
|
type="date"
|
||||||
|
required
|
||||||
|
value={date}
|
||||||
|
onChange={(event) => setDate(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{CRITERIA.map((criterion) => (
|
||||||
|
<div key={criterion.key} className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor={`evaluation-edit-${criterion.key}-${evaluation.id}`}>
|
||||||
|
{criterion.label} (1–5)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`evaluation-edit-${criterion.key}-${evaluation.id}`}
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={5}
|
||||||
|
required
|
||||||
|
value={scores[criterion.key]}
|
||||||
|
onChange={(event) =>
|
||||||
|
setScores((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[criterion.key]: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{error ? (
|
||||||
|
<p role="alert" className="text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" disabled={mutation.isPending}>
|
||||||
|
{mutation.isPending ? "Wird gespeichert…" : "Speichern"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function QueenEvaluationsCard({ queenId }: { queenId: string }) {
|
export function QueenEvaluationsCard({ queenId }: { queenId: string }) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -141,9 +261,12 @@ export function QueenEvaluationsCard({ queenId }: { queenId: string }) {
|
|||||||
) : null}
|
) : null}
|
||||||
{evaluations?.map((evaluation) => (
|
{evaluations?.map((evaluation) => (
|
||||||
<div key={evaluation.id} className="rounded-md border p-3">
|
<div key={evaluation.id} className="rounded-md border p-3">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
{new Date(evaluation.date).toLocaleDateString("de-DE")}
|
{new Date(evaluation.date).toLocaleDateString("de-DE")}
|
||||||
</p>
|
</p>
|
||||||
|
<EditEvaluationDialog evaluation={evaluation} queenId={queenId} />
|
||||||
|
</div>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Stärke {evaluation.colonyStrength} · Sanftmut {evaluation.gentleness} · Wabensitz{" "}
|
Stärke {evaluation.colonyStrength} · Sanftmut {evaluation.gentleness} · Wabensitz{" "}
|
||||||
{evaluation.combBuild} · Schwarmtrieb {evaluation.swarmingTendency} · Brutbild{" "}
|
{evaluation.combBuild} · Schwarmtrieb {evaluation.swarmingTendency} · Brutbild{" "}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { apiFetch } from "@/lib/auth/api-client";
|
|||||||
import {
|
import {
|
||||||
CreateHoneyBatchInput,
|
CreateHoneyBatchInput,
|
||||||
CreateHoneyMeasurementInput,
|
CreateHoneyMeasurementInput,
|
||||||
|
UpdateHoneyMeasurementInput,
|
||||||
HoneyBatch,
|
HoneyBatch,
|
||||||
HoneyBatchContainerAllocation,
|
HoneyBatchContainerAllocation,
|
||||||
HoneyBatchRelocation,
|
HoneyBatchRelocation,
|
||||||
@@ -47,6 +48,16 @@ export function updateHoneyBatch(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function updateHoneyMeasurement(
|
||||||
|
measurementId: string,
|
||||||
|
input: UpdateHoneyMeasurementInput,
|
||||||
|
): Promise<HoneyBatch> {
|
||||||
|
return apiFetch<HoneyBatch>(`/honey-batches/measurements/${measurementId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function addHoneyMeasurement(
|
export function addHoneyMeasurement(
|
||||||
id: string,
|
id: string,
|
||||||
input: CreateHoneyMeasurementInput,
|
input: CreateHoneyMeasurementInput,
|
||||||
|
|||||||
@@ -107,3 +107,11 @@ export interface CreateHoneyMeasurementInput {
|
|||||||
temperature?: number;
|
temperature?: number;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateHoneyMeasurementInput {
|
||||||
|
waterContent?: number;
|
||||||
|
measuredAt?: string;
|
||||||
|
device?: string;
|
||||||
|
temperature?: number;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { apiFetch } from "@/lib/auth/api-client";
|
|||||||
import {
|
import {
|
||||||
AssignColonyInput,
|
AssignColonyInput,
|
||||||
CreateQueenEvaluationInput,
|
CreateQueenEvaluationInput,
|
||||||
|
UpdateQueenEvaluationInput,
|
||||||
CreateQueenInput,
|
CreateQueenInput,
|
||||||
Queen,
|
Queen,
|
||||||
QueenColonyHistoryEntry,
|
QueenColonyHistoryEntry,
|
||||||
@@ -59,6 +60,16 @@ export function fetchQueenEvaluations(id: string): Promise<QueenEvaluation[]> {
|
|||||||
return apiFetch<QueenEvaluation[]>(`/queens/${id}/evaluations`);
|
return apiFetch<QueenEvaluation[]>(`/queens/${id}/evaluations`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function updateQueenEvaluation(
|
||||||
|
evaluationId: string,
|
||||||
|
input: UpdateQueenEvaluationInput,
|
||||||
|
): Promise<QueenEvaluation> {
|
||||||
|
return apiFetch<QueenEvaluation>(`/queens/evaluations/${evaluationId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function addQueenEvaluation(
|
export function addQueenEvaluation(
|
||||||
id: string,
|
id: string,
|
||||||
input: CreateQueenEvaluationInput,
|
input: CreateQueenEvaluationInput,
|
||||||
|
|||||||
@@ -124,3 +124,5 @@ export interface CreateQueenEvaluationInput {
|
|||||||
honeyYield: number;
|
honeyYield: number;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UpdateQueenEvaluationInput = Partial<CreateQueenEvaluationInput>;
|
||||||
Reference in new issue
Block a user