From 1a7331b6b295299443251b4863cbb8db95f27e08 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Tue, 25 Aug 2026 09:12:50 +0200 Subject: [PATCH] feat: Schwarmkontrolle-Protokoll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neues SwarmControlCheck-Modul: strukturierte Weiselzellenkontrollen pro Volk mit Zahl/Verdeckelungsstatus der Zellen, Schwarmstimmung, ergriffener Maßnahme und optionalem naechsten Kontrolltermin. Frontend: neue Karte auf der Volk-Detailseite, Erfassungsformular unter /swarm-control/new sowie Quick-Add-Eintrag. --- .../migration.sql | 31 +++ ApiCore-Backend/prisma/schema.prisma | 39 ++++ ApiCore-Backend/src/app.module.ts | 2 + .../colony-swarm-control-checks.controller.ts | 15 ++ .../dto/create-swarm-control-check.dto.ts | 50 +++++ .../dto/update-swarm-control-check.dto.ts | 14 ++ .../swarm-control/swarm-control.controller.ts | 45 ++++ .../src/swarm-control/swarm-control.module.ts | 11 + .../swarm-control.service.spec.ts | 111 +++++++++ .../swarm-control/swarm-control.service.ts | 88 ++++++++ .../test/swarm-control.e2e-spec.ts | 127 +++++++++++ .../colonies/[id]/colony-swarm-control.tsx | 70 ++++++ .../src/app/(app)/colonies/[id]/page.tsx | 2 + .../[id]/swarm-control-edit-dialog.tsx | 172 ++++++++++++++ .../src/app/(app)/quick-add-menu.tsx | 1 + .../src/app/(app)/swarm-control/new/page.tsx | 212 ++++++++++++++++++ ApiCore-Frontend/src/lib/swarm-control/api.ts | 29 +++ .../src/lib/swarm-control/labels.ts | 15 ++ .../src/lib/swarm-control/types.ts | 42 ++++ 19 files changed, 1076 insertions(+) create mode 100644 ApiCore-Backend/prisma/migrations/20260825065816_add_swarm_control_checks/migration.sql create mode 100644 ApiCore-Backend/src/swarm-control/colony-swarm-control-checks.controller.ts create mode 100644 ApiCore-Backend/src/swarm-control/dto/create-swarm-control-check.dto.ts create mode 100644 ApiCore-Backend/src/swarm-control/dto/update-swarm-control-check.dto.ts create mode 100644 ApiCore-Backend/src/swarm-control/swarm-control.controller.ts create mode 100644 ApiCore-Backend/src/swarm-control/swarm-control.module.ts create mode 100644 ApiCore-Backend/src/swarm-control/swarm-control.service.spec.ts create mode 100644 ApiCore-Backend/src/swarm-control/swarm-control.service.ts create mode 100644 ApiCore-Backend/test/swarm-control.e2e-spec.ts create mode 100644 ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-swarm-control.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/colonies/[id]/swarm-control-edit-dialog.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/swarm-control/new/page.tsx create mode 100644 ApiCore-Frontend/src/lib/swarm-control/api.ts create mode 100644 ApiCore-Frontend/src/lib/swarm-control/labels.ts create mode 100644 ApiCore-Frontend/src/lib/swarm-control/types.ts diff --git a/ApiCore-Backend/prisma/migrations/20260825065816_add_swarm_control_checks/migration.sql b/ApiCore-Backend/prisma/migrations/20260825065816_add_swarm_control_checks/migration.sql new file mode 100644 index 0000000..11f161b --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20260825065816_add_swarm_control_checks/migration.sql @@ -0,0 +1,31 @@ +-- CreateEnum +CREATE TYPE "SwarmMood" AS ENUM ('NONE', 'LOW', 'HIGH'); + +-- CreateEnum +CREATE TYPE "SwarmControlAction" AS ENUM ('NONE', 'QUEEN_CELLS_REMOVED', 'ARTIFICIAL_SWARM', 'COLONY_DIVIDED', 'OTHER'); + +-- CreateTable +CREATE TABLE "SwarmControlCheck" ( + "id" TEXT NOT NULL, + "colonyId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "date" TIMESTAMP(3) NOT NULL, + "queenCellsFound" INTEGER, + "queenCellsCapped" BOOLEAN NOT NULL DEFAULT false, + "swarmMood" "SwarmMood" NOT NULL DEFAULT 'NONE', + "actionTaken" "SwarmControlAction" NOT NULL DEFAULT 'NONE', + "notes" TEXT, + "nextCheckDate" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "SwarmControlCheck_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "SwarmControlCheck_colonyId_date_idx" ON "SwarmControlCheck"("colonyId", "date"); + +-- AddForeignKey +ALTER TABLE "SwarmControlCheck" ADD CONSTRAINT "SwarmControlCheck_colonyId_fkey" FOREIGN KEY ("colonyId") REFERENCES "Colony"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SwarmControlCheck" ADD CONSTRAINT "SwarmControlCheck_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/ApiCore-Backend/prisma/schema.prisma b/ApiCore-Backend/prisma/schema.prisma index 6d64135..8ee7514 100644 --- a/ApiCore-Backend/prisma/schema.prisma +++ b/ApiCore-Backend/prisma/schema.prisma @@ -39,6 +39,7 @@ model User { inspections Inspection[] varroaMeasurements VarroaMeasurement[] + swarmControlChecks SwarmControlCheck[] treatments Treatment[] feedings Feeding[] harvests Harvest[] @@ -175,6 +176,7 @@ model Colony { lineageAsResult ColonyLineage[] @relation("LineageTo") inspections Inspection[] varroaMeasurements VarroaMeasurement[] + swarmControlChecks SwarmControlCheck[] treatments Treatment[] feedings Feeding[] harvests HarvestColony[] @@ -457,6 +459,43 @@ model VarroaMeasurement { @@index([colonyId, date]) } +// --------------------------------------------------------------------------- +// Schwarmkontrolle (jenseits Lastenheft: strukturiertes Protokoll für +// Weiselzellenkontrollen während der Schwarmzeit) +// --------------------------------------------------------------------------- + +enum SwarmMood { + NONE + LOW + HIGH +} + +enum SwarmControlAction { + NONE + QUEEN_CELLS_REMOVED + ARTIFICIAL_SWARM + COLONY_DIVIDED + OTHER +} + +model SwarmControlCheck { + 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 + queenCellsFound Int? + queenCellsCapped Boolean @default(false) + swarmMood SwarmMood @default(NONE) + actionTaken SwarmControlAction @default(NONE) + notes String? + nextCheckDate DateTime? + createdAt DateTime @default(now()) + + @@index([colonyId, date]) +} + // --------------------------------------------------------------------------- // Behandlung / Tierarzneimittel (Abschnitt 4.11) // --------------------------------------------------------------------------- diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index 92ceac5..aaa7ef1 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -34,6 +34,7 @@ import { AutomationModule } from './automation/automation.module'; import { ImportModule } from './import/import.module'; import { PublicModule } from './public/public.module'; import { TaskTemplatesModule } from './task-templates/task-templates.module'; +import { SwarmControlModule } from './swarm-control/swarm-control.module'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { RolesGuard } from './auth/guards/roles.guard'; @@ -73,6 +74,7 @@ import { RolesGuard } from './auth/guards/roles.guard'; ImportModule, PublicModule, TaskTemplatesModule, + SwarmControlModule, ], controllers: [], providers: [ diff --git a/ApiCore-Backend/src/swarm-control/colony-swarm-control-checks.controller.ts b/ApiCore-Backend/src/swarm-control/colony-swarm-control-checks.controller.ts new file mode 100644 index 0000000..c18e364 --- /dev/null +++ b/ApiCore-Backend/src/swarm-control/colony-swarm-control-checks.controller.ts @@ -0,0 +1,15 @@ +import { Controller, Get, Param } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { SwarmControlService } from './swarm-control.service'; + +@ApiTags('swarm-control-checks') +@ApiBearerAuth() +@Controller('colonies') +export class ColonySwarmControlChecksController { + constructor(private readonly swarmControlService: SwarmControlService) {} + + @Get(':id/swarm-control-checks') + findByColony(@Param('id') id: string) { + return this.swarmControlService.findByColony(id); + } +} diff --git a/ApiCore-Backend/src/swarm-control/dto/create-swarm-control-check.dto.ts b/ApiCore-Backend/src/swarm-control/dto/create-swarm-control-check.dto.ts new file mode 100644 index 0000000..42eaca7 --- /dev/null +++ b/ApiCore-Backend/src/swarm-control/dto/create-swarm-control-check.dto.ts @@ -0,0 +1,50 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsBoolean, + IsDateString, + IsEnum, + IsInt, + IsOptional, + IsString, +} from 'class-validator'; +import { SwarmControlAction, SwarmMood } from '../../../generated/prisma'; + +export class CreateSwarmControlCheckDto { + @ApiProperty() + @IsString() + colonyId!: string; + + @ApiProperty() + @IsDateString() + date!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsInt() + queenCellsFound?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + queenCellsCapped?: boolean; + + @ApiPropertyOptional({ enum: SwarmMood }) + @IsOptional() + @IsEnum(SwarmMood) + swarmMood?: SwarmMood; + + @ApiPropertyOptional({ enum: SwarmControlAction }) + @IsOptional() + @IsEnum(SwarmControlAction) + actionTaken?: SwarmControlAction; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + notes?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + nextCheckDate?: string; +} diff --git a/ApiCore-Backend/src/swarm-control/dto/update-swarm-control-check.dto.ts b/ApiCore-Backend/src/swarm-control/dto/update-swarm-control-check.dto.ts new file mode 100644 index 0000000..ffc3400 --- /dev/null +++ b/ApiCore-Backend/src/swarm-control/dto/update-swarm-control-check.dto.ts @@ -0,0 +1,14 @@ +import { PartialType, PickType } from '@nestjs/swagger'; +import { CreateSwarmControlCheckDto } from './create-swarm-control-check.dto'; + +export class UpdateSwarmControlCheckDto extends PartialType( + PickType(CreateSwarmControlCheckDto, [ + 'date', + 'queenCellsFound', + 'queenCellsCapped', + 'swarmMood', + 'actionTaken', + 'notes', + 'nextCheckDate', + ] as const), +) {} diff --git a/ApiCore-Backend/src/swarm-control/swarm-control.controller.ts b/ApiCore-Backend/src/swarm-control/swarm-control.controller.ts new file mode 100644 index 0000000..483f595 --- /dev/null +++ b/ApiCore-Backend/src/swarm-control/swarm-control.controller.ts @@ -0,0 +1,45 @@ +import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { Role } from '../../generated/prisma'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import type { AuthenticatedUser } from '../auth/types/authenticated-user'; +import { CreateSwarmControlCheckDto } from './dto/create-swarm-control-check.dto'; +import { UpdateSwarmControlCheckDto } from './dto/update-swarm-control-check.dto'; +import { SwarmControlService } from './swarm-control.service'; + +@ApiTags('swarm-control-checks') +@ApiBearerAuth() +@Controller('swarm-control-checks') +export class SwarmControlController { + constructor(private readonly swarmControlService: SwarmControlService) {} + + @Get() + findAll() { + return this.swarmControlService.findAll(); + } + + @Get(':id') + findOne(@Param('id') id: string) { + return this.swarmControlService.findById(id); + } + + @Roles(Role.ADMIN, Role.BEEKEEPER) + @Post() + create( + @Body() dto: CreateSwarmControlCheckDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.swarmControlService.create(dto, actor.id); + } + + @Roles(Role.ADMIN, Role.BEEKEEPER) + @Patch(':id') + update( + @Param('id') id: string, + @Body() dto: UpdateSwarmControlCheckDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.swarmControlService.update(id, dto, actor.id); + } +} diff --git a/ApiCore-Backend/src/swarm-control/swarm-control.module.ts b/ApiCore-Backend/src/swarm-control/swarm-control.module.ts new file mode 100644 index 0000000..08c273f --- /dev/null +++ b/ApiCore-Backend/src/swarm-control/swarm-control.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { SwarmControlController } from './swarm-control.controller'; +import { ColonySwarmControlChecksController } from './colony-swarm-control-checks.controller'; +import { SwarmControlService } from './swarm-control.service'; + +@Module({ + controllers: [SwarmControlController, ColonySwarmControlChecksController], + providers: [SwarmControlService], + exports: [SwarmControlService], +}) +export class SwarmControlModule {} diff --git a/ApiCore-Backend/src/swarm-control/swarm-control.service.spec.ts b/ApiCore-Backend/src/swarm-control/swarm-control.service.spec.ts new file mode 100644 index 0000000..118a8a2 --- /dev/null +++ b/ApiCore-Backend/src/swarm-control/swarm-control.service.spec.ts @@ -0,0 +1,111 @@ +import { NotFoundException } from '@nestjs/common'; +import { SwarmControlService } from './swarm-control.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditLogService } from '../audit/audit-log.service'; + +describe('SwarmControlService', () => { + let service: SwarmControlService; + let prisma: { + swarmControlCheck: { + findMany: jest.Mock; + findUnique: jest.Mock; + create: jest.Mock; + update: jest.Mock; + }; + }; + let auditLog: { record: jest.Mock }; + + const check = { + id: 'check-1', + colonyId: 'colony-1', + date: new Date('2026-05-01T00:00:00.000Z'), + }; + + beforeEach(() => { + prisma = { + swarmControlCheck: { + findMany: jest.fn(), + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, + }; + auditLog = { record: jest.fn() }; + service = new SwarmControlService( + prisma as unknown as PrismaService, + auditLog as unknown as AuditLogService, + ); + }); + + it('findById throws NotFoundException when missing', async () => { + prisma.swarmControlCheck.findUnique.mockResolvedValue(null); + await expect(service.findById('missing')).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it('create stores the acting user and converts date fields', async () => { + prisma.swarmControlCheck.create.mockResolvedValue(check); + + await service.create( + { + colonyId: 'colony-1', + date: '2026-05-01', + nextCheckDate: '2026-05-08', + }, + 'user-1', + ); + + const [[createArg]] = prisma.swarmControlCheck.create.mock.calls as [ + [ + { + data: { + colonyId: string; + userId: string; + date: Date; + nextCheckDate: Date; + }; + }, + ], + ]; + expect(createArg.data.colonyId).toBe('colony-1'); + expect(createArg.data.userId).toBe('user-1'); + expect(createArg.data.date).toBeInstanceOf(Date); + expect(createArg.data.nextCheckDate).toBeInstanceOf(Date); + }); + + it('update writes an audit log entry', async () => { + prisma.swarmControlCheck.findUnique.mockResolvedValue(check); + const updated = { ...check, queenCellsFound: 3, queenCellsCapped: true }; + prisma.swarmControlCheck.update.mockResolvedValue(updated); + + const result = await service.update( + 'check-1', + { queenCellsFound: 3, queenCellsCapped: true }, + 'user-1', + ); + + expect(result).toBe(updated); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: 'SwarmControlCheck', + entityId: 'check-1', + action: 'UPDATE', + beforeData: check, + afterData: updated, + }), + ); + }); + + it('findByColony orders results chronologically', async () => { + prisma.swarmControlCheck.findMany.mockResolvedValue([check]); + + await service.findByColony('colony-1'); + + const [[queryArg]] = prisma.swarmControlCheck.findMany.mock.calls as [ + [{ where: { colonyId: string }; orderBy: { date: string } }], + ]; + expect(queryArg.where.colonyId).toBe('colony-1'); + expect(queryArg.orderBy.date).toBe('asc'); + }); +}); diff --git a/ApiCore-Backend/src/swarm-control/swarm-control.service.ts b/ApiCore-Backend/src/swarm-control/swarm-control.service.ts new file mode 100644 index 0000000..b039647 --- /dev/null +++ b/ApiCore-Backend/src/swarm-control/swarm-control.service.ts @@ -0,0 +1,88 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditLogService } from '../audit/audit-log.service'; +import { SwarmControlCheck } from '../../generated/prisma'; +import { CreateSwarmControlCheckDto } from './dto/create-swarm-control-check.dto'; +import { UpdateSwarmControlCheckDto } from './dto/update-swarm-control-check.dto'; + +@Injectable() +export class SwarmControlService { + constructor( + private readonly prisma: PrismaService, + private readonly auditLog: AuditLogService, + ) {} + + async findAll(): Promise { + return this.prisma.swarmControlCheck.findMany({ + orderBy: { date: 'desc' }, + }); + } + + async findById(id: string): Promise { + const check = await this.prisma.swarmControlCheck.findUnique({ + where: { id }, + }); + if (!check) { + throw new NotFoundException('Swarm control check not found'); + } + return check; + } + + async findByColony(colonyId: string): Promise { + return this.prisma.swarmControlCheck.findMany({ + where: { colonyId }, + orderBy: { date: 'asc' }, + }); + } + + async create( + dto: CreateSwarmControlCheckDto, + actingUserId: string, + ): Promise { + const check = await this.prisma.swarmControlCheck.create({ + data: { + ...dto, + date: new Date(dto.date), + nextCheckDate: dto.nextCheckDate + ? new Date(dto.nextCheckDate) + : undefined, + userId: actingUserId, + }, + }); + await this.auditLog.record({ + entityType: 'SwarmControlCheck', + entityId: check.id, + action: 'CREATE', + userId: actingUserId, + afterData: check, + }); + return check; + } + + async update( + id: string, + dto: UpdateSwarmControlCheckDto, + actingUserId: string, + ): Promise { + const before = await this.findById(id); + const check = await this.prisma.swarmControlCheck.update({ + where: { id }, + data: { + ...dto, + date: dto.date ? new Date(dto.date) : undefined, + nextCheckDate: dto.nextCheckDate + ? new Date(dto.nextCheckDate) + : undefined, + }, + }); + await this.auditLog.record({ + entityType: 'SwarmControlCheck', + entityId: id, + action: 'UPDATE', + userId: actingUserId, + beforeData: before, + afterData: check, + }); + return check; + } +} diff --git a/ApiCore-Backend/test/swarm-control.e2e-spec.ts b/ApiCore-Backend/test/swarm-control.e2e-spec.ts new file mode 100644 index 0000000..f0bc5d8 --- /dev/null +++ b/ApiCore-Backend/test/swarm-control.e2e-spec.ts @@ -0,0 +1,127 @@ +import { INestApplication } from '@nestjs/common'; +import * as bcrypt from 'bcryptjs'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { createTestApp } from './utils/create-test-app'; +import { login } from './utils/api'; + +interface SwarmControlCheckBody { + id: string; + colonyId: string; + queenCellsCapped: boolean; +} + +describe('Swarm control checks (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + const beekeeperEmail = `swarm-control-e2e-beekeeper-${Date.now()}@example.com`; + const staffEmail = `swarm-control-e2e-staff-${Date.now()}@example.com`; + const password = 'SuperSecret123!'; + let beekeeperId: string; + let staffId: string; + let beekeeperToken: string; + let staffToken: string; + let apiaryId: string; + let colonyId: string; + let checkId: string; + + beforeAll(async () => { + app = await createTestApp(); + prisma = app.get(PrismaService); + + const passwordHash = await bcrypt.hash(password, 12); + const beekeeper = await prisma.user.create({ + data: { + email: beekeeperEmail, + passwordHash, + name: 'Beekeeper', + role: 'BEEKEEPER', + }, + }); + beekeeperId = beekeeper.id; + const staff = await prisma.user.create({ + data: { email: staffEmail, passwordHash, name: 'Staff', role: 'STAFF' }, + }); + staffId = staff.id; + beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken; + staffToken = (await login(app, staffEmail, password)).accessToken; + + const apiary = await prisma.apiary.create({ + data: { name: `Stand ${Date.now()}` }, + }); + apiaryId = apiary.id; + const colony = await prisma.colony.create({ + data: { + code: `V-${Date.now()}`, + apiaryId, + colonyType: 'PRODUCTION_COLONY', + }, + }); + colonyId = colony.id; + }); + + afterAll(async () => { + await prisma.swarmControlCheck.deleteMany({ where: { colonyId } }); + await prisma.colony.deleteMany({ where: { id: colonyId } }); + await prisma.apiary.deleteMany({ where: { id: apiaryId } }); + await prisma.refreshToken.deleteMany({ + where: { userId: { in: [beekeeperId, staffId] } }, + }); + await prisma.auditLog.deleteMany({ + where: { userId: { in: [beekeeperId, staffId] } }, + }); + await prisma.user.deleteMany({ + where: { id: { in: [beekeeperId, staffId] } }, + }); + await app.close(); + }); + + it('rejects create for a STAFF user', () => { + return request(app.getHttpServer()) + .post('/api/v1/swarm-control-checks') + .set('Authorization', `Bearer ${staffToken}`) + .send({ colonyId, date: new Date().toISOString() }) + .expect(403); + }); + + it('lets a BEEKEEPER user record a check', async () => { + const response = await request(app.getHttpServer()) + .post('/api/v1/swarm-control-checks') + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ + colonyId, + date: new Date().toISOString(), + queenCellsFound: 2, + queenCellsCapped: false, + swarmMood: 'LOW', + }) + .expect(201); + checkId = (response.body as SwarmControlCheckBody).id; + expect(checkId).toEqual(expect.any(String)); + }); + + it('lists checks for the colony', async () => { + const response = await request(app.getHttpServer()) + .get(`/api/v1/colonies/${colonyId}/swarm-control-checks`) + .set('Authorization', `Bearer ${staffToken}`) + .expect(200); + const checks = response.body as SwarmControlCheckBody[]; + expect(checks.some((entry) => entry.id === checkId)).toBe(true); + }); + + it('updates a check to record capped queen cells', async () => { + const response = await request(app.getHttpServer()) + .patch(`/api/v1/swarm-control-checks/${checkId}`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ + queenCellsCapped: true, + actionTaken: 'ARTIFICIAL_SWARM', + nextCheckDate: new Date().toISOString(), + }) + .expect(200); + expect((response.body as SwarmControlCheckBody).queenCellsCapped).toBe( + true, + ); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-swarm-control.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-swarm-control.tsx new file mode 100644 index 0000000..5f8b88f --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-swarm-control.tsx @@ -0,0 +1,70 @@ +"use client"; + +import Link from "next/link"; +import { Sprout } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; +import { fetchColonySwarmControlChecks } from "@/lib/swarm-control/api"; +import { SWARM_ACTION_LABELS, SWARM_MOOD_LABELS } from "@/lib/swarm-control/labels"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { EmptyState } from "@/components/empty-state"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { SwarmControlEditDialog } from "./swarm-control-edit-dialog"; + +export function ColonySwarmControlCard({ colonyId }: { colonyId: string }) { + const { data: checks, isLoading } = useQuery({ + queryKey: ["colonies", colonyId, "swarm-control-checks"], + queryFn: () => fetchColonySwarmControlChecks(colonyId), + }); + + return ( + + + Schwarmkontrolle + + + + {isLoading ?

Lädt…

: null} + {!isLoading && (!checks || checks.length === 0) ? ( + + ) : null} + {checks?.map((check) => ( +
+
+

{new Date(check.date).toLocaleDateString("de-DE")}

+ +
+

+ {check.queenCellsFound !== null + ? `${check.queenCellsFound} Weiselzelle(n) gefunden` + : "Keine Weiselzellen erfasst"} + {check.queenCellsCapped ? ( + + Verdeckelt + + ) : null} +

+

Schwarmstimmung: {SWARM_MOOD_LABELS[check.swarmMood]}

+ {check.actionTaken !== "NONE" ? ( +

Maßnahme: {SWARM_ACTION_LABELS[check.actionTaken]}

+ ) : null} + {check.nextCheckDate ? ( +

Nächste Kontrolle: {new Date(check.nextCheckDate).toLocaleDateString("de-DE")}

+ ) : null} + {check.notes ?

{check.notes}

: null} +
+ ))} +
+
+ ); +} diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/page.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/page.tsx index 4e3d531..01d791e 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/page.tsx @@ -10,6 +10,7 @@ import { ColonyActions } from "./colony-actions"; import { ColonyHistoryCard } from "./colony-history"; import { ColonyInspectionsCard } from "./colony-inspections"; import { ColonyVarroaCard } from "./colony-varroa"; +import { ColonySwarmControlCard } from "./colony-swarm-control"; import { ColonyTreatmentsCard } from "./colony-treatments"; import { ColonyFeedingsCard } from "./colony-feedings"; @@ -40,6 +41,7 @@ export default function ColonyDetailPage() { + diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/swarm-control-edit-dialog.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/swarm-control-edit-dialog.tsx new file mode 100644 index 0000000..4cca543 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/swarm-control-edit-dialog.tsx @@ -0,0 +1,172 @@ +"use client"; + +import { FormEvent, useState } from "react"; +import { Pencil } from "lucide-react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { updateSwarmControlCheck } from "@/lib/swarm-control/api"; +import { SWARM_ACTION_LABELS, SWARM_MOOD_LABELS } from "@/lib/swarm-control/labels"; +import { SwarmControlAction, SwarmControlCheck, SwarmMood } from "@/lib/swarm-control/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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; + +export function SwarmControlEditDialog({ check }: { check: SwarmControlCheck }) { + const queryClient = useQueryClient(); + const [open, setOpen] = useState(false); + const [date, setDate] = useState(check.date.slice(0, 10)); + const [queenCellsFound, setQueenCellsFound] = useState(check.queenCellsFound?.toString() ?? ""); + const [queenCellsCapped, setQueenCellsCapped] = useState(check.queenCellsCapped); + const [swarmMood, setSwarmMood] = useState(check.swarmMood); + const [actionTaken, setActionTaken] = useState(check.actionTaken); + const [nextCheckDate, setNextCheckDate] = useState(check.nextCheckDate?.slice(0, 10) ?? ""); + const [notes, setNotes] = useState(check.notes ?? ""); + + const mutation = useMutation({ + mutationFn: () => + updateSwarmControlCheck(check.id, { + date: new Date(date).toISOString(), + queenCellsFound: queenCellsFound ? Number(queenCellsFound) : undefined, + queenCellsCapped, + swarmMood, + actionTaken, + nextCheckDate: nextCheckDate ? new Date(nextCheckDate).toISOString() : undefined, + notes: notes || undefined, + }), + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: ["colonies", check.colonyId, "swarm-control-checks"], + }); + setOpen(false); + }, + }); + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + mutation.mutate(); + } + + return ( + + + + Bearbeiten + + } + /> + + + Schwarmkontrolle bearbeiten + +
+
+ + setDate(event.target.value)} + /> +
+
+ + setQueenCellsFound(event.target.value)} + /> +
+ +
+ + +
+
+ + +
+
+ + setNextCheckDate(event.target.value)} + /> +
+
+ +