feat: Schwarmkontrolle-Protokoll

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.
This commit is contained in:
Nick Szaunig committed 2026-08-25 09:12:50 +02:00
1 parent 4609a0f9ec
commit 1a7331b6b2
19 files changed
+1076

No files matched your search

@@ -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;
+39
View File
@@ -39,6 +39,7 @@ model User {
inspections Inspection[] inspections Inspection[]
varroaMeasurements VarroaMeasurement[] varroaMeasurements VarroaMeasurement[]
swarmControlChecks SwarmControlCheck[]
treatments Treatment[] treatments Treatment[]
feedings Feeding[] feedings Feeding[]
harvests Harvest[] harvests Harvest[]
@@ -175,6 +176,7 @@ model Colony {
lineageAsResult ColonyLineage[] @relation("LineageTo") lineageAsResult ColonyLineage[] @relation("LineageTo")
inspections Inspection[] inspections Inspection[]
varroaMeasurements VarroaMeasurement[] varroaMeasurements VarroaMeasurement[]
swarmControlChecks SwarmControlCheck[]
treatments Treatment[] treatments Treatment[]
feedings Feeding[] feedings Feeding[]
harvests HarvestColony[] harvests HarvestColony[]
@@ -457,6 +459,43 @@ model VarroaMeasurement {
@@index([colonyId, date]) @@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) // Behandlung / Tierarzneimittel (Abschnitt 4.11)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+2
View File
@@ -34,6 +34,7 @@ import { AutomationModule } from './automation/automation.module';
import { ImportModule } from './import/import.module'; import { ImportModule } from './import/import.module';
import { PublicModule } from './public/public.module'; import { PublicModule } from './public/public.module';
import { TaskTemplatesModule } from './task-templates/task-templates.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 { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { RolesGuard } from './auth/guards/roles.guard'; import { RolesGuard } from './auth/guards/roles.guard';
@@ -73,6 +74,7 @@ import { RolesGuard } from './auth/guards/roles.guard';
ImportModule, ImportModule,
PublicModule, PublicModule,
TaskTemplatesModule, TaskTemplatesModule,
SwarmControlModule,
], ],
controllers: [], controllers: [],
providers: [ providers: [
@@ -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);
}
}
@@ -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;
}
@@ -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),
) {}
@@ -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);
}
}
@@ -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 {}
@@ -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');
});
});
@@ -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<SwarmControlCheck[]> {
return this.prisma.swarmControlCheck.findMany({
orderBy: { date: 'desc' },
});
}
async findById(id: string): Promise<SwarmControlCheck> {
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<SwarmControlCheck[]> {
return this.prisma.swarmControlCheck.findMany({
where: { colonyId },
orderBy: { date: 'asc' },
});
}
async create(
dto: CreateSwarmControlCheckDto,
actingUserId: string,
): Promise<SwarmControlCheck> {
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<SwarmControlCheck> {
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;
}
}
@@ -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<App>;
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,
);
});
});
@@ -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 (
<Card className="max-w-lg">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Schwarmkontrolle</CardTitle>
<Button
size="sm"
nativeButton={false}
render={<Link href={`/swarm-control/new?colonyId=${colonyId}`} />}
>
Neue Kontrolle
</Button>
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
{isLoading ? <p className="text-muted-foreground">Lädt…</p> : null}
{!isLoading && (!checks || checks.length === 0) ? (
<EmptyState
icon={Sprout}
title="Noch keine Schwarmkontrolle"
description="Erfasse die erste Weiselzellenkontrolle für dieses Volk."
/>
) : null}
{checks?.map((check) => (
<div key={check.id} className="rounded-md border p-3">
<div className="flex items-center justify-between">
<p className="font-medium">{new Date(check.date).toLocaleDateString("de-DE")}</p>
<SwarmControlEditDialog check={check} />
</div>
<p className="text-muted-foreground">
{check.queenCellsFound !== null
? `${check.queenCellsFound} Weiselzelle(n) gefunden`
: "Keine Weiselzellen erfasst"}
{check.queenCellsCapped ? (
<Badge variant="destructive" className="ml-2">
Verdeckelt
</Badge>
) : null}
</p>
<p>Schwarmstimmung: {SWARM_MOOD_LABELS[check.swarmMood]}</p>
{check.actionTaken !== "NONE" ? (
<p>Maßnahme: {SWARM_ACTION_LABELS[check.actionTaken]}</p>
) : null}
{check.nextCheckDate ? (
<p>Nächste Kontrolle: {new Date(check.nextCheckDate).toLocaleDateString("de-DE")}</p>
) : null}
{check.notes ? <p>{check.notes}</p> : null}
</div>
))}
</CardContent>
</Card>
);
}
@@ -10,6 +10,7 @@ import { ColonyActions } from "./colony-actions";
import { ColonyHistoryCard } from "./colony-history"; import { ColonyHistoryCard } from "./colony-history";
import { ColonyInspectionsCard } from "./colony-inspections"; import { ColonyInspectionsCard } from "./colony-inspections";
import { ColonyVarroaCard } from "./colony-varroa"; import { ColonyVarroaCard } from "./colony-varroa";
import { ColonySwarmControlCard } from "./colony-swarm-control";
import { ColonyTreatmentsCard } from "./colony-treatments"; import { ColonyTreatmentsCard } from "./colony-treatments";
import { ColonyFeedingsCard } from "./colony-feedings"; import { ColonyFeedingsCard } from "./colony-feedings";
@@ -40,6 +41,7 @@ export default function ColonyDetailPage() {
<ColonyActions colony={colony} /> <ColonyActions colony={colony} />
<ColonyInspectionsCard colonyId={colony.id} /> <ColonyInspectionsCard colonyId={colony.id} />
<ColonyVarroaCard colonyId={colony.id} /> <ColonyVarroaCard colonyId={colony.id} />
<ColonySwarmControlCard colonyId={colony.id} />
<ColonyTreatmentsCard colonyId={colony.id} /> <ColonyTreatmentsCard colonyId={colony.id} />
<ColonyFeedingsCard colonyId={colony.id} /> <ColonyFeedingsCard colonyId={colony.id} />
<ColonyHistoryCard colonyId={colony.id} /> <ColonyHistoryCard colonyId={colony.id} />
@@ -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<SwarmMood>(check.swarmMood);
const [actionTaken, setActionTaken] = useState<SwarmControlAction>(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<HTMLFormElement>) {
event.preventDefault();
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>Schwarmkontrolle bearbeiten</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="swarm-edit-date">Datum</Label>
<Input
id="swarm-edit-date"
type="date"
value={date}
onChange={(event) => setDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="swarm-edit-cells-found">Weiselzellen gefunden</Label>
<Input
id="swarm-edit-cells-found"
type="number"
min={0}
value={queenCellsFound}
onChange={(event) => setQueenCellsFound(event.target.value)}
/>
</div>
<label
className="flex h-9 items-center gap-3 text-sm"
htmlFor="swarm-edit-cells-capped"
>
<input
id="swarm-edit-cells-capped"
type="checkbox"
className="size-4.5"
checked={queenCellsCapped}
onChange={(event) => setQueenCellsCapped(event.target.checked)}
/>
Weiselzellen bereits verdeckelt (akut)
</label>
<div className="flex flex-col gap-2">
<Label htmlFor="swarm-edit-mood">Schwarmstimmung</Label>
<Select
value={swarmMood}
onValueChange={(value) => setSwarmMood((value ?? swarmMood) as SwarmMood)}
>
<SelectTrigger id="swarm-edit-mood" className="w-full">
<SelectValue>{SWARM_MOOD_LABELS[swarmMood]}</SelectValue>
</SelectTrigger>
<SelectContent>
{(Object.keys(SWARM_MOOD_LABELS) as SwarmMood[]).map((option) => (
<SelectItem key={option} value={option}>
{SWARM_MOOD_LABELS[option]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="swarm-edit-action">Maßnahme</Label>
<Select
value={actionTaken}
onValueChange={(value) =>
setActionTaken((value ?? actionTaken) as SwarmControlAction)
}
>
<SelectTrigger id="swarm-edit-action" className="w-full">
<SelectValue>{SWARM_ACTION_LABELS[actionTaken]}</SelectValue>
</SelectTrigger>
<SelectContent>
{(Object.keys(SWARM_ACTION_LABELS) as SwarmControlAction[]).map((option) => (
<SelectItem key={option} value={option}>
{SWARM_ACTION_LABELS[option]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="swarm-edit-next-check">Nächste Kontrolle</Label>
<Input
id="swarm-edit-next-check"
type="date"
value={nextCheckDate}
onChange={(event) => setNextCheckDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="swarm-edit-notes">Notizen</Label>
<Textarea
id="swarm-edit-notes"
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
</div>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Speichert…" : "Speichern"}
</Button>
</form>
</DialogContent>
</Dialog>
);
}
@@ -13,6 +13,7 @@ import {
const QUICK_ACTIONS = [ const QUICK_ACTIONS = [
{ label: "Durchsicht", href: "/inspections/new" }, { label: "Durchsicht", href: "/inspections/new" },
{ label: "Varroa-Messung", href: "/varroa/new" }, { label: "Varroa-Messung", href: "/varroa/new" },
{ label: "Schwarmkontrolle", href: "/swarm-control/new" },
{ label: "Behandlung", href: "/treatments/new" }, { label: "Behandlung", href: "/treatments/new" },
{ label: "Fütterung", href: "/feedings/new" }, { label: "Fütterung", href: "/feedings/new" },
{ label: "Aufgabe", href: "/tasks/new" }, { label: "Aufgabe", href: "/tasks/new" },
@@ -0,0 +1,212 @@
"use client";
import { FormEvent, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Sprout } from "lucide-react";
import { fetchColonies } from "@/lib/colonies/api";
import { createSwarmControlCheck } from "@/lib/swarm-control/api";
import { SWARM_ACTION_LABELS, SWARM_MOOD_LABELS } from "@/lib/swarm-control/labels";
import { SwarmControlAction, 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 { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
function todayIsoDate(): string {
return new Date().toISOString().slice(0, 10);
}
export default function NewSwarmControlCheckPage() {
const router = useRouter();
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const { data: colonies } = useQuery({
queryKey: ["colonies"],
queryFn: () => fetchColonies(),
});
const activeColonies = colonies?.filter((colony) => colony.status === "ACTIVE") ?? [];
const [colonyId, setColonyId] = useState(searchParams.get("colonyId") ?? "");
const [date, setDate] = useState(todayIsoDate());
const [queenCellsFound, setQueenCellsFound] = useState("");
const [queenCellsCapped, setQueenCellsCapped] = useState(false);
const [swarmMood, setSwarmMood] = useState<SwarmMood>("NONE");
const [actionTaken, setActionTaken] = useState<SwarmControlAction>("NONE");
const [nextCheckDate, setNextCheckDate] = useState("");
const [notes, setNotes] = useState("");
const [error, setError] = useState<string | null>(null);
const mutation = useMutation({
mutationFn: createSwarmControlCheck,
onSuccess: (check) => {
void queryClient.invalidateQueries({
queryKey: ["colonies", check.colonyId, "swarm-control-checks"],
});
toast.success("Schwarmkontrolle gespeichert");
router.push(`/colonies/${check.colonyId}`);
},
onError: () => {
setError("Schwarmkontrolle konnte nicht gespeichert werden.");
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
if (!colonyId) {
setError("Bitte ein Volk auswählen.");
return;
}
mutation.mutate({
colonyId,
date: new Date(date).toISOString(),
queenCellsFound: queenCellsFound ? Number(queenCellsFound) : undefined,
queenCellsCapped,
swarmMood,
actionTaken,
nextCheckDate: nextCheckDate ? new Date(nextCheckDate).toISOString() : undefined,
notes: notes || undefined,
});
}
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={Sprout}
title="Neue Schwarmkontrolle"
description="Erfasse eine Weiselzellenkontrolle für ein Volk."
gradient="var(--gradient-sky)"
/>
<Card className="max-w-lg">
<CardContent>
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="swarm-colony">Volk</Label>
<Select value={colonyId} onValueChange={(value) => setColonyId(value ?? "")}>
<SelectTrigger id="swarm-colony" className="h-11 w-full">
<SelectValue placeholder="Volk wählen" />
</SelectTrigger>
<SelectContent>
{activeColonies.map((colony) => (
<SelectItem key={colony.id} value={colony.id}>
{colony.code}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="swarm-date">Datum</Label>
<Input
id="swarm-date"
type="date"
required
className="h-11"
value={date}
onChange={(event) => setDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="swarm-cells-found">Weiselzellen gefunden</Label>
<Input
id="swarm-cells-found"
type="number"
min={0}
inputMode="numeric"
className="h-11"
value={queenCellsFound}
onChange={(event) => setQueenCellsFound(event.target.value)}
/>
</div>
<label className="flex h-9 items-center gap-3 text-sm" htmlFor="swarm-cells-capped">
<input
id="swarm-cells-capped"
type="checkbox"
className="size-4.5"
checked={queenCellsCapped}
onChange={(event) => setQueenCellsCapped(event.target.checked)}
/>
Weiselzellen bereits verdeckelt (akut)
</label>
<div className="flex flex-col gap-2">
<Label htmlFor="swarm-mood">Schwarmstimmung</Label>
<Select
value={swarmMood}
onValueChange={(value) => setSwarmMood((value ?? swarmMood) as SwarmMood)}
>
<SelectTrigger id="swarm-mood" className="h-11 w-full">
<SelectValue>{SWARM_MOOD_LABELS[swarmMood]}</SelectValue>
</SelectTrigger>
<SelectContent>
{(Object.keys(SWARM_MOOD_LABELS) as SwarmMood[]).map((option) => (
<SelectItem key={option} value={option}>
{SWARM_MOOD_LABELS[option]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="swarm-action">Maßnahme</Label>
<Select
value={actionTaken}
onValueChange={(value) =>
setActionTaken((value ?? actionTaken) as SwarmControlAction)
}
>
<SelectTrigger id="swarm-action" className="h-11 w-full">
<SelectValue>{SWARM_ACTION_LABELS[actionTaken]}</SelectValue>
</SelectTrigger>
<SelectContent>
{(Object.keys(SWARM_ACTION_LABELS) as SwarmControlAction[]).map((option) => (
<SelectItem key={option} value={option}>
{SWARM_ACTION_LABELS[option]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="swarm-next-check">Nächste Kontrolle</Label>
<Input
id="swarm-next-check"
type="date"
className="h-11"
value={nextCheckDate}
onChange={(event) => setNextCheckDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="swarm-notes">Notizen</Label>
<Textarea
id="swarm-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" className="h-12 text-base" disabled={mutation.isPending}>
{mutation.isPending ? "Wird gespeichert…" : "Kontrolle speichern"}
</Button>
</form>
</CardContent>
</Card>
</main>
);
}
@@ -0,0 +1,29 @@
import { apiFetch } from "@/lib/auth/api-client";
import {
CreateSwarmControlCheckInput,
SwarmControlCheck,
UpdateSwarmControlCheckInput,
} from "./types";
export function fetchColonySwarmControlChecks(colonyId: string): Promise<SwarmControlCheck[]> {
return apiFetch<SwarmControlCheck[]>(`/colonies/${colonyId}/swarm-control-checks`);
}
export function createSwarmControlCheck(
input: CreateSwarmControlCheckInput,
): Promise<SwarmControlCheck> {
return apiFetch<SwarmControlCheck>("/swarm-control-checks", {
method: "POST",
body: JSON.stringify(input),
});
}
export function updateSwarmControlCheck(
id: string,
input: UpdateSwarmControlCheckInput,
): Promise<SwarmControlCheck> {
return apiFetch<SwarmControlCheck>(`/swarm-control-checks/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
@@ -0,0 +1,15 @@
import { SwarmControlAction, SwarmMood } from "./types";
export const SWARM_MOOD_LABELS: Record<SwarmMood, string> = {
NONE: "Keine",
LOW: "Gering",
HIGH: "Hoch",
};
export const SWARM_ACTION_LABELS: Record<SwarmControlAction, string> = {
NONE: "Keine",
QUEEN_CELLS_REMOVED: "Weiselzellen gebrochen",
ARTIFICIAL_SWARM: "Kunstschwarm gebildet",
COLONY_DIVIDED: "Volk geteilt",
OTHER: "Sonstiges",
};
@@ -0,0 +1,42 @@
export type SwarmMood = "NONE" | "LOW" | "HIGH";
export type SwarmControlAction =
| "NONE"
| "QUEEN_CELLS_REMOVED"
| "ARTIFICIAL_SWARM"
| "COLONY_DIVIDED"
| "OTHER";
export interface SwarmControlCheck {
id: string;
colonyId: string;
userId: string;
date: string;
queenCellsFound: number | null;
queenCellsCapped: boolean;
swarmMood: SwarmMood;
actionTaken: SwarmControlAction;
notes: string | null;
nextCheckDate: string | null;
createdAt: string;
}
export interface CreateSwarmControlCheckInput {
colonyId: string;
date: string;
queenCellsFound?: number;
queenCellsCapped?: boolean;
swarmMood?: SwarmMood;
actionTaken?: SwarmControlAction;
notes?: string;
nextCheckDate?: string;
}
export interface UpdateSwarmControlCheckInput {
date?: string;
queenCellsFound?: number;
queenCellsCapped?: boolean;
swarmMood?: SwarmMood;
actionTaken?: SwarmControlAction;
notes?: string;
nextCheckDate?: string;
}