feat: warn against harvesting honey during a treatment's Wartezeit

Treatments (Ameisensäure, Oxalsäure, etc.) can require a legally
mandated withdrawal period before honey from that colony may be sold.
Nothing in the app tracked this before, so a beekeeper had no
safeguard against harvesting into an active withdrawal window.

Treatments now optionally record withdrawalPeriodDays (from the
product's Packungsbeilage). The new harvest form fetches each
candidate colony's active withdrawal window and shows an inline
warning naming the product and the date it clears, computed as
max(endDate ?? date) + withdrawalPeriodDays across a colony's
treatments, restricted to windows still in the future.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Sonnet 5 committed 2026-09-06 02:50:34 +02:00
1 parent 0881a38800
commit d42f8331f9
13 files changed
+221 -7

No files matched your search

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Treatment" ADD COLUMN "withdrawalPeriodDays" INTEGER;
+1
View File
@@ -584,6 +584,7 @@ model Treatment {
reason String?
batchNumber String?
notes String?
withdrawalPeriodDays Int?
createdAt DateTime @default(now())
@@index([colonyId, date])
@@ -1,5 +1,11 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsDateString, IsOptional, IsString } from 'class-validator';
import {
IsDateString,
IsInt,
IsOptional,
IsString,
Min,
} from 'class-validator';
export class CreateTreatmentDto {
@ApiProperty()
@@ -53,4 +59,13 @@ export class CreateTreatmentDto {
@IsOptional()
@IsString()
notes?: string;
@ApiPropertyOptional({
description:
'Wartezeit in Tagen ab Behandlungsende, bevor Honig aus diesem Volk geerntet werden darf.',
})
@IsOptional()
@IsInt()
@Min(0)
withdrawalPeriodDays?: number;
}
@@ -13,5 +13,6 @@ export class UpdateTreatmentDto extends PartialType(
'reason',
'batchNumber',
'notes',
'withdrawalPeriodDays',
] as const),
) {}
@@ -1,4 +1,12 @@
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
@@ -19,6 +27,13 @@ export class TreatmentsController {
return this.treatmentsService.findAll();
}
@RequirePermission('treatments:read')
@Get('withdrawal-status')
getWithdrawalStatus(@Query('colonyIds') colonyIds?: string) {
const ids = colonyIds ? colonyIds.split(',').filter(Boolean) : [];
return this.treatmentsService.getWithdrawalStatus(ids);
}
@RequirePermission('treatments:read')
@Get(':id')
findOne(@Param('id') id: string) {
@@ -97,4 +97,58 @@ describe('TreatmentsService', () => {
expect(queryArg.where.colonyId).toBe('colony-1');
expect(queryArg.orderBy.date).toBe('asc');
});
describe('getWithdrawalStatus', () => {
it('returns an empty array without querying when no colony ids are given', async () => {
const result = await service.getWithdrawalStatus([]);
expect(result).toEqual([]);
expect(prisma.treatment.findMany).not.toHaveBeenCalled();
});
it('reports the treatment whose withdrawal window reaches furthest into the future', async () => {
const now = Date.now();
const inTenDays = new Date(now + 10 * 24 * 60 * 60 * 1000);
const inFiveDays = new Date(now + 5 * 24 * 60 * 60 * 1000);
prisma.treatment.findMany.mockResolvedValue([
{
colonyId: 'colony-1',
date: inTenDays,
endDate: null,
withdrawalPeriodDays: 0,
product: 'Ameisensäure',
},
{
colonyId: 'colony-1',
date: inFiveDays,
endDate: null,
withdrawalPeriodDays: 21,
product: 'Api Bioxal',
},
]);
const result = await service.getWithdrawalStatus(['colony-1']);
expect(result).toHaveLength(1);
expect(result[0].colonyId).toBe('colony-1');
expect(result[0].product).toBe('Api Bioxal');
});
it('omits a colony whose withdrawal window has already passed', async () => {
const longAgo = new Date('2020-01-01');
prisma.treatment.findMany.mockResolvedValue([
{
colonyId: 'colony-1',
date: longAgo,
endDate: null,
withdrawalPeriodDays: 14,
product: 'Ameisensäure',
},
]);
const result = await service.getWithdrawalStatus(['colony-1']);
expect(result).toEqual([]);
});
});
});
@@ -6,6 +6,12 @@ import { Treatment } from '../../generated/prisma';
import { CreateTreatmentDto } from './dto/create-treatment.dto';
import { UpdateTreatmentDto } from './dto/update-treatment.dto';
export interface WithdrawalStatus {
colonyId: string;
activeUntil: string;
product: string;
}
@Injectable()
export class TreatmentsService {
constructor(
@@ -33,6 +39,44 @@ export class TreatmentsService {
});
}
// For each colony, returns the treatment whose withdrawal window (its
// end date, or start date if the treatment is still ongoing, plus its
// withdrawalPeriodDays) reaches furthest into the future - only when
// that window is still active. Colonies with no active window are
// omitted rather than returned as null, since most colonies have none.
async getWithdrawalStatus(colonyIds: string[]): Promise<WithdrawalStatus[]> {
if (colonyIds.length === 0) {
return [];
}
const treatments = await this.prisma.treatment.findMany({
where: {
colonyId: { in: colonyIds },
withdrawalPeriodDays: { not: null },
},
});
const now = new Date();
const activeByColony = new Map<string, WithdrawalStatus>();
for (const treatment of treatments) {
const referenceDate = treatment.endDate ?? treatment.date;
const activeUntil = new Date(referenceDate);
activeUntil.setUTCDate(
activeUntil.getUTCDate() + (treatment.withdrawalPeriodDays ?? 0),
);
if (activeUntil <= now) {
continue;
}
const current = activeByColony.get(treatment.colonyId);
if (!current || activeUntil > new Date(current.activeUntil)) {
activeByColony.set(treatment.colonyId, {
colonyId: treatment.colonyId,
activeUntil: activeUntil.toISOString(),
product: treatment.product,
});
}
}
return [...activeByColony.values()];
}
async create(
dto: CreateTreatmentDto,
actingUserId: string,
@@ -49,6 +49,9 @@ export function ColonyTreatmentsCard({ colonyId }: { colonyId: string }) {
{treatment.method ? ` · ${treatment.method}` : ""}
</p>
{treatment.reason ? <p>Grund: {treatment.reason}</p> : null}
{treatment.withdrawalPeriodDays !== null ? (
<p>Wartezeit: {treatment.withdrawalPeriodDays} Tag(e)</p>
) : null}
</div>
))}
</CardContent>
@@ -26,6 +26,9 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
const [method, setMethod] = useState(treatment.method ?? "");
const [reason, setReason] = useState(treatment.reason ?? "");
const [notes, setNotes] = useState(treatment.notes ?? "");
const [withdrawalPeriodDays, setWithdrawalPeriodDays] = useState(
treatment.withdrawalPeriodDays?.toString() ?? "",
);
const mutation = useMutation({
mutationFn: () =>
@@ -36,6 +39,7 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
method: method || undefined,
reason: reason || undefined,
notes: notes || undefined,
withdrawalPeriodDays: withdrawalPeriodDays ? Number(withdrawalPeriodDays) : undefined,
}),
onSuccess: () => {
void queryClient.invalidateQueries({
@@ -107,6 +111,17 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
onChange={(event) => setReason(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="treatment-edit-withdrawal">Wartezeit (Tage)</Label>
<Input
id="treatment-edit-withdrawal"
type="number"
inputMode="numeric"
min={0}
value={withdrawalPeriodDays}
onChange={(event) => setWithdrawalPeriodDays(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="treatment-edit-notes">Notizen</Label>
<Textarea
@@ -4,10 +4,11 @@ import { FormEvent, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Boxes } from "lucide-react";
import { Boxes, AlertTriangle } from "lucide-react";
import { fetchApiaries } from "@/lib/apiaries/api";
import { fetchColonies } from "@/lib/colonies/api";
import { createHarvest } from "@/lib/harvests/api";
import { fetchWithdrawalStatus } from "@/lib/treatments/api";
import { ApiError } from "@/lib/auth/api-client";
import { queueHarvest } from "@/lib/offline/db";
import { Button } from "@/components/ui/button";
@@ -47,6 +48,15 @@ export default function NewHarvestPage() {
const apiaryColonies = (colonies ?? []).filter(
(colony) => colony.apiaryId === apiaryId && colony.status === "ACTIVE",
);
const apiaryColonyIds = apiaryColonies.map((colony) => colony.id);
const { data: withdrawalStatuses } = useQuery({
queryKey: ["treatments", "withdrawal-status", apiaryColonyIds],
queryFn: () => fetchWithdrawalStatus(apiaryColonyIds),
enabled: apiaryColonyIds.length > 0,
});
const withdrawalByColony = new Map(
(withdrawalStatuses ?? []).map((status) => [status.colonyId, status]),
);
const mutation = useMutation({
mutationFn: createHarvest,
@@ -144,9 +154,11 @@ export default function NewHarvestPage() {
</p>
) : null}
<div className="flex flex-col gap-2">
{apiaryColonies.map((colony) => (
{apiaryColonies.map((colony) => {
const withdrawal = withdrawalByColony.get(colony.id);
return (
<div key={colony.id} className="flex flex-col gap-1">
<label
key={colony.id}
className="flex h-11 items-center gap-3 text-sm"
htmlFor={`harvest-colony-${colony.id}`}
>
@@ -159,7 +171,16 @@ export default function NewHarvestPage() {
/>
{colony.code}
</label>
))}
{withdrawal ? (
<p className="flex items-center gap-1.5 pl-8 text-xs text-destructive">
<AlertTriangle className="size-3.5 shrink-0" />
Wartezeit ({withdrawal.product}) bis{" "}
{new Date(withdrawal.activeUntil).toLocaleDateString("de-DE")}
</p>
) : null}
</div>
);
})}
</div>
</div>
<div className="flex flex-col gap-2">
@@ -45,6 +45,7 @@ export default function NewTreatmentPage() {
const [method, setMethod] = useState("");
const [reason, setReason] = useState("");
const [notes, setNotes] = useState("");
const [withdrawalPeriodDays, setWithdrawalPeriodDays] = useState("");
const [error, setError] = useState<string | null>(null);
const mutation = useMutation({
@@ -83,6 +84,7 @@ export default function NewTreatmentPage() {
method: method || undefined,
reason: reason || undefined,
notes: notes || undefined,
withdrawalPeriodDays: withdrawalPeriodDays ? Number(withdrawalPeriodDays) : undefined,
});
}
@@ -160,6 +162,22 @@ export default function NewTreatmentPage() {
onChange={(event) => setReason(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="treatment-withdrawal">Wartezeit (Tage)</Label>
<Input
id="treatment-withdrawal"
type="number"
inputMode="numeric"
min={0}
className="h-11"
value={withdrawalPeriodDays}
onChange={(event) => setWithdrawalPeriodDays(event.target.value)}
/>
<p className="text-xs text-muted-foreground">
Laut Packungsbeilage des Mittels – solange darf aus diesem Volk kein Honig
geerntet werden.
</p>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="treatment-notes">Notizen</Label>
<Textarea
+17 -1
View File
@@ -1,10 +1,26 @@
import { apiFetch } from "@/lib/auth/api-client";
import { CreateTreatmentInput, Treatment, UpdateTreatmentInput } from "./types";
import {
CreateTreatmentInput,
Treatment,
UpdateTreatmentInput,
WithdrawalStatus,
} from "./types";
export function fetchColonyTreatments(colonyId: string): Promise<Treatment[]> {
return apiFetch<Treatment[]>(`/colonies/${colonyId}/treatments`);
}
export function fetchWithdrawalStatus(
colonyIds: string[],
): Promise<WithdrawalStatus[]> {
if (colonyIds.length === 0) {
return Promise.resolve([]);
}
return apiFetch<WithdrawalStatus[]>(
`/treatments/withdrawal-status?colonyIds=${colonyIds.join(",")}`,
);
}
export function createTreatment(input: CreateTreatmentInput): Promise<Treatment> {
return apiFetch<Treatment>("/treatments", {
method: "POST",
@@ -12,6 +12,7 @@ export interface Treatment {
reason: string | null;
batchNumber: string | null;
notes: string | null;
withdrawalPeriodDays: number | null;
createdAt: string;
}
@@ -27,6 +28,7 @@ export interface CreateTreatmentInput {
reason?: string;
batchNumber?: string;
notes?: string;
withdrawalPeriodDays?: number;
}
export interface UpdateTreatmentInput {
@@ -40,4 +42,11 @@ export interface UpdateTreatmentInput {
reason?: string;
batchNumber?: string;
notes?: string;
withdrawalPeriodDays?: number;
}
export interface WithdrawalStatus {
colonyId: string;
activeUntil: string;
product: string;
}