diff --git a/ApiCore-Backend/prisma/migrations/20260906120000_add_treatment_withdrawal_period/migration.sql b/ApiCore-Backend/prisma/migrations/20260906120000_add_treatment_withdrawal_period/migration.sql
new file mode 100644
index 0000000..8b3b0bf
--- /dev/null
+++ b/ApiCore-Backend/prisma/migrations/20260906120000_add_treatment_withdrawal_period/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "Treatment" ADD COLUMN "withdrawalPeriodDays" INTEGER;
diff --git a/ApiCore-Backend/prisma/schema.prisma b/ApiCore-Backend/prisma/schema.prisma
index 5ad443f..4d56a5c 100644
--- a/ApiCore-Backend/prisma/schema.prisma
+++ b/ApiCore-Backend/prisma/schema.prisma
@@ -569,22 +569,23 @@ model SwarmControlCheck {
// ---------------------------------------------------------------------------
model Treatment {
- id String @id @default(cuid())
- colonyId String
- colony Colony @relation(fields: [colonyId], references: [id])
- userId String
- user User @relation(fields: [userId], references: [id])
- date DateTime
- startDate DateTime?
- endDate DateTime?
- product String
- activeIngredient String?
- amount String?
- method String?
- reason String?
- batchNumber String?
- notes String?
- createdAt DateTime @default(now())
+ id String @id @default(cuid())
+ colonyId String
+ colony Colony @relation(fields: [colonyId], references: [id])
+ userId String
+ user User @relation(fields: [userId], references: [id])
+ date DateTime
+ startDate DateTime?
+ endDate DateTime?
+ product String
+ activeIngredient String?
+ amount String?
+ method String?
+ reason String?
+ batchNumber String?
+ notes String?
+ withdrawalPeriodDays Int?
+ createdAt DateTime @default(now())
@@index([colonyId, date])
}
@@ -708,9 +709,9 @@ model HoneyBatch {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
- measurements HoneyMeasurement[]
- bottlingBatches BottlingBatch[]
- relocations HoneyBatchRelocation[]
+ measurements HoneyMeasurement[]
+ bottlingBatches BottlingBatch[]
+ relocations HoneyBatchRelocation[]
containerAllocations HoneyBatchContainerAllocation[]
@@index([status])
@@ -757,10 +758,10 @@ model StorageContainer {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
- honeyBatches HoneyBatch[]
- relocationsFrom HoneyBatchRelocation[] @relation("RelocationFrom")
- relocationsTo HoneyBatchRelocation[] @relation("RelocationTo")
- batchAllocations HoneyBatchContainerAllocation[]
+ honeyBatches HoneyBatch[]
+ relocationsFrom HoneyBatchRelocation[] @relation("RelocationFrom")
+ relocationsTo HoneyBatchRelocation[] @relation("RelocationTo")
+ batchAllocations HoneyBatchContainerAllocation[]
@@index([status])
}
@@ -879,14 +880,14 @@ model Product {
// Etikettenvorlage mit {{Platzhalter}}-Text, wird beim Drucken mit den
// Daten einer Abfuellung befuellt (siehe LabelTemplatesService.renderLabelsPdf).
model LabelTemplate {
- id String @id @default(cuid())
- name String
+ id String @id @default(cuid())
+ name String
// Array of LabelElement objects (text/placeholder/qrCode/image/line), see
// label-templates.service.ts. Drives both the in-browser canvas designer
// and the pdfkit print renderer from the same source of truth.
- elements Json @default("[]")
- widthMm Float @default(90)
- heightMm Float @default(35)
+ elements Json @default("[]")
+ widthMm Float @default(90)
+ heightMm Float @default(35)
// A4 sheet layout. columns/rows are nullable: null means "auto-fit as many
// as fit" (the original behaviour); a real label sheet product (e.g. a
// specific Avery SKU) publishes an exact columns/rows/margin/gap grid, so
@@ -894,14 +895,14 @@ model LabelTemplate {
// pre-die-cut sheet instead of just whatever the naive fit computes.
columns Int?
rows Int?
- marginTopMm Float @default(5)
- marginLeftMm Float @default(5)
- gapXMm Float @default(2)
- gapYMm Float @default(2)
- isDefault Boolean @default(false)
- active Boolean @default(true)
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
+ marginTopMm Float @default(5)
+ marginLeftMm Float @default(5)
+ gapXMm Float @default(2)
+ gapYMm Float @default(2)
+ isDefault Boolean @default(false)
+ active Boolean @default(true)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
@@index([active])
}
diff --git a/ApiCore-Backend/src/treatments/dto/create-treatment.dto.ts b/ApiCore-Backend/src/treatments/dto/create-treatment.dto.ts
index d2ff58f..4ede718 100644
--- a/ApiCore-Backend/src/treatments/dto/create-treatment.dto.ts
+++ b/ApiCore-Backend/src/treatments/dto/create-treatment.dto.ts
@@ -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;
}
diff --git a/ApiCore-Backend/src/treatments/dto/update-treatment.dto.ts b/ApiCore-Backend/src/treatments/dto/update-treatment.dto.ts
index 76ffa27..36817e5 100644
--- a/ApiCore-Backend/src/treatments/dto/update-treatment.dto.ts
+++ b/ApiCore-Backend/src/treatments/dto/update-treatment.dto.ts
@@ -13,5 +13,6 @@ export class UpdateTreatmentDto extends PartialType(
'reason',
'batchNumber',
'notes',
+ 'withdrawalPeriodDays',
] as const),
) {}
diff --git a/ApiCore-Backend/src/treatments/treatments.controller.ts b/ApiCore-Backend/src/treatments/treatments.controller.ts
index c4c4dc7..6ee3e5e 100644
--- a/ApiCore-Backend/src/treatments/treatments.controller.ts
+++ b/ApiCore-Backend/src/treatments/treatments.controller.ts
@@ -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) {
diff --git a/ApiCore-Backend/src/treatments/treatments.service.spec.ts b/ApiCore-Backend/src/treatments/treatments.service.spec.ts
index d9ef64d..6468f7b 100644
--- a/ApiCore-Backend/src/treatments/treatments.service.spec.ts
+++ b/ApiCore-Backend/src/treatments/treatments.service.spec.ts
@@ -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([]);
+ });
+ });
});
diff --git a/ApiCore-Backend/src/treatments/treatments.service.ts b/ApiCore-Backend/src/treatments/treatments.service.ts
index 1e49a35..c8b605f 100644
--- a/ApiCore-Backend/src/treatments/treatments.service.ts
+++ b/ApiCore-Backend/src/treatments/treatments.service.ts
@@ -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
Grund: {treatment.reason}
: null} + {treatment.withdrawalPeriodDays !== null ? ( +Wartezeit: {treatment.withdrawalPeriodDays} Tag(e)
+ ) : null} ))} diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/treatment-edit-dialog.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/treatment-edit-dialog.tsx index 82f9001..5e1fa61 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/treatment-edit-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/treatment-edit-dialog.tsx @@ -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)} /> ++ Laut Packungsbeilage des Mittels – solange darf aus diesem Volk kein Honig + geerntet werden. +
+