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 { + 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(); + 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, diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-treatments.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-treatments.tsx index f0299d6..39c604d 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-treatments.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-treatments.tsx @@ -49,6 +49,9 @@ export function ColonyTreatmentsCard({ colonyId }: { colonyId: string }) { {treatment.method ? ` · ${treatment.method}` : ""}

{treatment.reason ?

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)} /> +
+ + setWithdrawalPeriodDays(event.target.value)} + /> +