diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e367d58..a12a3fd 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -105,6 +105,7 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige 14. **API-Schlüssel für Maschinen-Zugriffe**: Integrationen (Wabenhain-Shop) nutzen einen Schlüssel `ack_…` im `Authorization: Bearer`-Header statt eines Service-Kontos mit Passwort. Gespeichert wird nur der SHA-256-Hash (32 Zufallsbytes, daher kein langsamer KDF nötig); der Klartext erscheint einmal bei der Erstellung. Ein Schlüssel ist kein Benutzer: `JwtAuthGuard` setzt `request.apiKey` statt `request.user`, `PermissionsGuard` lässt ihn nur auf Routen, deren Berechtigung in seinen Scopes steht – `@AnyAuthenticatedUser`-Routen nie. Erlaubte Scopes sind eine kurze Allowlist (`api-keys/api-key-scopes.ts`, derzeit nur `shop-sync:read`). Verwaltung mit `api-keys:manage`. Schlüssel sind bewusst nicht Teil des Backups: nach einer Wiederherstellung wird ein neuer Schlüssel ausgestellt. 13. **Lebende vs. beendete Völker**: Nur `DISSOLVED`, `SOLD`, `DECEASED`, `MERGED` sperren Umsetzen/Teilen/Vereinigen/Königinzuweisung (`colonies/colony-status.ts`). `WEAK`, `CONSPICUOUS`, `QUEENLESS` bleiben bearbeitbar. 15. **Bestandsbuch aus Behandlungen, nicht als eigenes Register**: Das Bestandsbuch nach Art. 108 VO (EU) 2019/6 wird aus `Treatment` erzeugt (`export/treatment-log.ts`); Lieferant, Erwerbsbeleg und verschreibender Tierarzt sind Freitextfelder der Behandlung, Name/Anschrift des Halters liegen in `SiteSettings`. Der Stand am Behandlungstag wird beim Anlegen als `Treatment.apiaryId` festgehalten (Umsetzen ändert ihn nicht nachträglich); für Altbestand wird er aus `ColonyApiaryHistory` abgeleitet und im PDF bei Unsicherheit markiert. Fehlende Pflichtangaben werden als „FEHLT“ gedruckt statt still weggelassen. Keine Wartezeit-Automatik (siehe 10). +16. **Überwinterung als eigene Einträge statt Volksstatus**: `WinterCheck` (eindeutig je Volk, `winterYear`, Saison) hält Ein- und Auswinterung; ab Juli gehört ein Datum zum Winter desselben Jahres. Verlustursachen sind bewusst nur die COLOSS-Kategorien, keine Diagnosen. Die Übersicht gruppiert nach dem aktuellen Stand des Volkes. Ein Verlust ändert den Volksstatus nur auf ausdrücklichen Wunsch (`markColonyDeceased`). Neue Rechte vergibt die Migration an Nicht-System-Rollen, weil der Katalog-Sync nur System-Rollen berechtigt. ## 7. Kubernetes diff --git a/ApiCore-Backend/prisma/migrations/20260928091348_winter_checks/migration.sql b/ApiCore-Backend/prisma/migrations/20260928091348_winter_checks/migration.sql new file mode 100644 index 0000000..248c572 --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20260928091348_winter_checks/migration.sql @@ -0,0 +1,60 @@ +-- CreateEnum +CREATE TYPE "WinterCheckSeason" AS ENUM ('AUTUMN', 'SPRING'); + +-- CreateEnum +CREATE TYPE "WinterOutcome" AS ENUM ('SURVIVED', 'WEAK', 'LOST'); + +-- CreateEnum +CREATE TYPE "ColonyLossCause" AS ENUM ('DEAD_OR_EMPTY', 'QUEEN_PROBLEM', 'NATURAL_DISASTER'); + +-- CreateTable +CREATE TABLE "WinterCheck" ( + "id" TEXT NOT NULL, + "colonyId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "winterYear" INTEGER NOT NULL, + "season" "WinterCheckSeason" NOT NULL, + "date" TIMESTAMP(3) NOT NULL, + "occupiedSeams" INTEGER, + "feedStockKg" DOUBLE PRECISION, + "queenPresent" BOOLEAN, + "outcome" "WinterOutcome", + "lossCause" "ColonyLossCause", + "notes" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "WinterCheck_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "WinterCheck_winterYear_idx" ON "WinterCheck"("winterYear"); + +-- CreateIndex +CREATE UNIQUE INDEX "WinterCheck_colonyId_winterYear_season_key" ON "WinterCheck"("colonyId", "winterYear", "season"); + +-- AddForeignKey +ALTER TABLE "WinterCheck" ADD CONSTRAINT "WinterCheck_colonyId_fkey" FOREIGN KEY ("colonyId") REFERENCES "Colony"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WinterCheck" ADD CONSTRAINT "WinterCheck_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- Permissions: the boot-time catalog sync only grants new permissions to +-- system roles (ADMIN). Winter checks are everyday beekeeping records, so the +-- other roles get the same access they have to inspections. +INSERT INTO "Permission" (id, key, resource, action, "labelDe", "groupDe", "sortOrder") +VALUES + (gen_random_uuid()::text, 'winter-checks:read', 'winter-checks', 'read', 'Ansehen', 'Überwinterung', 850), + (gen_random_uuid()::text, 'winter-checks:create', 'winter-checks', 'create', 'Anlegen', 'Überwinterung', 851), + (gen_random_uuid()::text, 'winter-checks:update', 'winter-checks', 'update', 'Bearbeiten', 'Überwinterung', 852) +ON CONFLICT (key) DO NOTHING; + +INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt") +SELECT r.id, p.id, now() +FROM "Role" r, "Permission" p +WHERE p.resource = 'winter-checks' + AND ( + r.key IN ('ADMIN', 'BEEKEEPER', 'STAFF') + OR (r.key IN ('READONLY', 'API_CLIENT') AND p.action = 'read') + ) +ON CONFLICT DO NOTHING; diff --git a/ApiCore-Backend/prisma/schema.prisma b/ApiCore-Backend/prisma/schema.prisma index 00d5d3d..e410a8d 100644 --- a/ApiCore-Backend/prisma/schema.prisma +++ b/ApiCore-Backend/prisma/schema.prisma @@ -93,6 +93,7 @@ model User { inspections Inspection[] varroaMeasurements VarroaMeasurement[] swarmControlChecks SwarmControlCheck[] + winterChecks WinterCheck[] treatments Treatment[] feedings Feeding[] harvests Harvest[] @@ -270,6 +271,7 @@ model Colony { inspections Inspection[] varroaMeasurements VarroaMeasurement[] swarmControlChecks SwarmControlCheck[] + winterChecks WinterCheck[] treatments Treatment[] feedings Feeding[] harvests HarvestColony[] @@ -595,6 +597,54 @@ model SwarmControlCheck { @@index([colonyId, date]) } +// --------------------------------------------------------------------------- +// Ein- und Auswinterung, Winterverluste +// --------------------------------------------------------------------------- + +enum WinterCheckSeason { + AUTUMN // Einwinterung + SPRING // Auswinterung +} + +enum WinterOutcome { + SURVIVED + WEAK + LOST +} + +// Verlustkategorien des COLOSS-Monitorings: totes/leeres Volk, unloesbares +// Koeniginnenproblem, Naturereignis. Bewusst keine Diagnose (Varroa, Virus +// ...): die gehoert, wenn ueberhaupt bekannt, in die Notiz. +enum ColonyLossCause { + DEAD_OR_EMPTY + QUEEN_PROBLEM + NATURAL_DISASTER +} + +// Eine Einwinterung und eine Auswinterung je Volk und Winter. winterYear ist +// das Jahr, in dem der Winter beginnt (Winter 2026/27 = 2026). +model WinterCheck { + id String @id @default(cuid()) + colonyId String + colony Colony @relation(fields: [colonyId], references: [id]) + userId String + user User @relation(fields: [userId], references: [id]) + winterYear Int + season WinterCheckSeason + date DateTime + occupiedSeams Int? + feedStockKg Float? + queenPresent Boolean? + outcome WinterOutcome? + lossCause ColonyLossCause? + notes String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([colonyId, winterYear, season]) + @@index([winterYear]) +} + // --------------------------------------------------------------------------- // Behandlung / Tierarzneimittel (Abschnitt 4.11) // --------------------------------------------------------------------------- diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index 8ef6346..1c76317 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -40,6 +40,7 @@ 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 { WinterChecksModule } from './winter-checks/winter-checks.module'; import { WeatherModule } from './weather/weather.module'; import { ProfileModule } from './profile/profile.module'; import { SiteSettingsModule } from './site-settings/site-settings.module'; @@ -95,6 +96,7 @@ import { IdempotencyCleanupService } from './common/idempotency/idempotency-clea PublicModule, TaskTemplatesModule, SwarmControlModule, + WinterChecksModule, WeatherModule, ProfileModule, SiteSettingsModule, diff --git a/ApiCore-Backend/src/auth/permissions/permission-catalog.ts b/ApiCore-Backend/src/auth/permissions/permission-catalog.ts index 1f7a945..bc47606 100644 --- a/ApiCore-Backend/src/auth/permissions/permission-catalog.ts +++ b/ApiCore-Backend/src/auth/permissions/permission-catalog.ts @@ -111,6 +111,18 @@ export const PERMISSION_CATALOG = [ ], 800, ), + // Seeded with role grants by the winter_checks migration (everyday records + // like inspections, not an admin-only area). + ...group( + 'Überwinterung', + 'winter-checks', + [ + ['read', 'Ansehen'], + ['create', 'Anlegen'], + ['update', 'Bearbeiten'], + ], + 850, + ), ...group( 'Behandlungen', 'treatments', diff --git a/ApiCore-Backend/src/backup/backup.service.spec.ts b/ApiCore-Backend/src/backup/backup.service.spec.ts index 7f16e7b..47ae7e4 100644 --- a/ApiCore-Backend/src/backup/backup.service.spec.ts +++ b/ApiCore-Backend/src/backup/backup.service.spec.ts @@ -24,6 +24,7 @@ const BUSINESS_ENTITY_KEYS = [ 'task', 'varroaMeasurement', 'swarmControlCheck', + 'winterCheck', 'treatment', 'feeding', 'harvest', diff --git a/ApiCore-Backend/src/backup/backup.service.ts b/ApiCore-Backend/src/backup/backup.service.ts index 11be9ee..bdd0824 100644 --- a/ApiCore-Backend/src/backup/backup.service.ts +++ b/ApiCore-Backend/src/backup/backup.service.ts @@ -93,6 +93,7 @@ export class BackupService { data.task = await this.prisma.task.findMany(); data.varroaMeasurement = await this.prisma.varroaMeasurement.findMany(); data.swarmControlCheck = await this.prisma.swarmControlCheck.findMany(); + data.winterCheck = await this.prisma.winterCheck.findMany(); data.treatment = await this.prisma.treatment.findMany(); data.feeding = await this.prisma.feeding.findMany(); data.harvest = await this.prisma.harvest.findMany(); @@ -430,6 +431,21 @@ export class BackupService { } imported.swarmControlCheck = rowsOf('swarmControlCheck').length; + for (const row of rowsOf('winterCheck')) { + await tx.winterCheck.upsert({ + where: { id: row.id as string }, + create: { + ...row, + userId: resolveUserId(asString(row, 'userId')), + } as never, + update: { + ...row, + userId: resolveUserId(asString(row, 'userId')), + } as never, + }); + } + imported.winterCheck = rowsOf('winterCheck').length; + for (const row of rowsOf('treatment')) { await tx.treatment.upsert({ where: { id: row.id as string }, diff --git a/ApiCore-Backend/src/winter-checks/colony-winter-checks.controller.ts b/ApiCore-Backend/src/winter-checks/colony-winter-checks.controller.ts new file mode 100644 index 0000000..c7c0283 --- /dev/null +++ b/ApiCore-Backend/src/winter-checks/colony-winter-checks.controller.ts @@ -0,0 +1,17 @@ +import { Controller, Get, Param } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { RequirePermission } from '../auth/decorators/require-permission.decorator'; +import { WinterChecksService } from './winter-checks.service'; + +@ApiTags('winter-checks') +@ApiBearerAuth() +@Controller('colonies') +export class ColonyWinterChecksController { + constructor(private readonly winterChecksService: WinterChecksService) {} + + @RequirePermission('winter-checks:read') + @Get(':id/winter-checks') + findByColony(@Param('id') id: string) { + return this.winterChecksService.findByColony(id); + } +} diff --git a/ApiCore-Backend/src/winter-checks/dto/create-winter-check.dto.ts b/ApiCore-Backend/src/winter-checks/dto/create-winter-check.dto.ts new file mode 100644 index 0000000..48c691f --- /dev/null +++ b/ApiCore-Backend/src/winter-checks/dto/create-winter-check.dto.ts @@ -0,0 +1,88 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsBoolean, + IsDateString, + IsEnum, + IsInt, + IsNumber, + IsOptional, + IsString, + Max, + MaxLength, + Min, +} from 'class-validator'; +import { + ColonyLossCause, + WinterCheckSeason, + WinterOutcome, +} from '../../../generated/prisma'; + +export class CreateWinterCheckDto { + @ApiProperty() + @IsString() + colonyId!: string; + + @ApiProperty({ enum: WinterCheckSeason }) + @IsEnum(WinterCheckSeason) + season!: WinterCheckSeason; + + @ApiProperty() + @IsDateString() + date!: string; + + @ApiPropertyOptional({ + description: + 'Jahr, in dem der Winter beginnt (2026 = Winter 2026/27). Ohne Angabe aus dem Datum abgeleitet.', + }) + @IsOptional() + @IsInt() + @Min(2000) + @Max(2100) + winterYear?: number; + + @ApiPropertyOptional({ description: 'Besetzte Wabengassen' }) + @IsOptional() + @IsInt() + @Min(0) + @Max(60) + occupiedSeams?: number; + + @ApiPropertyOptional({ description: 'Geschätzter Futtervorrat in kg' }) + @IsOptional() + @IsNumber() + @Min(0) + @Max(100) + feedStockKg?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + queenPresent?: boolean; + + @ApiPropertyOptional({ enum: WinterOutcome, description: 'Nur Auswinterung' }) + @IsOptional() + @IsEnum(WinterOutcome) + outcome?: WinterOutcome; + + @ApiPropertyOptional({ + enum: ColonyLossCause, + description: 'Nur bei Ergebnis LOST (Kategorien des COLOSS-Monitorings)', + }) + @IsOptional() + @IsEnum(ColonyLossCause) + lossCause?: ColonyLossCause; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(2000) + notes?: string; + + @ApiPropertyOptional({ + description: + 'Bei Ergebnis LOST das Volk zugleich als abgestorben (DECEASED) kennzeichnen.', + }) + @IsOptional() + @IsBoolean() + markColonyDeceased?: boolean; +} diff --git a/ApiCore-Backend/src/winter-checks/dto/update-winter-check.dto.ts b/ApiCore-Backend/src/winter-checks/dto/update-winter-check.dto.ts new file mode 100644 index 0000000..aebf265 --- /dev/null +++ b/ApiCore-Backend/src/winter-checks/dto/update-winter-check.dto.ts @@ -0,0 +1,9 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateWinterCheckDto } from './create-winter-check.dto'; + +/** + * Every field stays correctable, colony and season included: a check filed + * against the wrong colony or season is a data-entry error, and re-filing it + * is the only way to repair the winter record without deleting it. + */ +export class UpdateWinterCheckDto extends PartialType(CreateWinterCheckDto) {} diff --git a/ApiCore-Backend/src/winter-checks/dto/winter-check-query.dto.ts b/ApiCore-Backend/src/winter-checks/dto/winter-check-query.dto.ts new file mode 100644 index 0000000..45a1113 --- /dev/null +++ b/ApiCore-Backend/src/winter-checks/dto/winter-check-query.dto.ts @@ -0,0 +1,18 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; + +export class WinterCheckQueryDto { + @ApiPropertyOptional({ description: '2026 = Winter 2026/27' }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(2000) + @Max(2100) + winterYear?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + apiaryId?: string; +} diff --git a/ApiCore-Backend/src/winter-checks/winter-check-rules.spec.ts b/ApiCore-Backend/src/winter-checks/winter-check-rules.spec.ts new file mode 100644 index 0000000..ed7ab3b --- /dev/null +++ b/ApiCore-Backend/src/winter-checks/winter-check-rules.spec.ts @@ -0,0 +1,153 @@ +import { + summarizeWinter, + validateWinterCheck, + WinterSummarySource, + winterYearFor, +} from './winter-check-rules'; + +const d = (value: string) => new Date(`${value}T12:00:00.000Z`); + +describe('winterYearFor', () => { + it('assigns autumn dates to the winter starting that year', () => { + expect(winterYearFor(d('2026-09-15'))).toBe(2026); + expect(winterYearFor(d('2026-07-01'))).toBe(2026); + }); + + it('assigns spring dates to the winter that started the year before', () => { + expect(winterYearFor(d('2027-03-20'))).toBe(2026); + expect(winterYearFor(d('2027-06-30'))).toBe(2026); + }); +}); + +describe('validateWinterCheck', () => { + it('accepts a plain autumn check', () => { + expect( + validateWinterCheck({ season: 'AUTUMN', outcome: null, lossCause: null }), + ).toBeNull(); + }); + + it('rejects an outcome on an autumn check', () => { + expect( + validateWinterCheck({ + season: 'AUTUMN', + outcome: 'SURVIVED', + lossCause: null, + }), + ).toMatch(/Einwinterung/); + }); + + it('requires an outcome on a spring check', () => { + expect( + validateWinterCheck({ season: 'SPRING', outcome: null, lossCause: null }), + ).toMatch(/Ergebnis/); + }); + + it('requires a loss cause exactly when the colony was lost', () => { + expect( + validateWinterCheck({ + season: 'SPRING', + outcome: 'LOST', + lossCause: null, + }), + ).toMatch(/Verlustursache/); + expect( + validateWinterCheck({ + season: 'SPRING', + outcome: 'SURVIVED', + lossCause: 'DEAD_OR_EMPTY', + }), + ).toMatch(/Verlustursache/); + expect( + validateWinterCheck({ + season: 'SPRING', + outcome: 'LOST', + lossCause: 'QUEEN_PROBLEM', + }), + ).toBeNull(); + }); +}); + +function check(overrides: Partial): WinterSummarySource { + return { + colonyId: 'c1', + apiaryId: 'a', + apiaryName: 'Stand A', + season: 'AUTUMN', + outcome: null, + lossCause: null, + ...overrides, + }; +} + +describe('summarizeWinter', () => { + it('counts outcomes against the colonies that went into winter', () => { + const summary = summarizeWinter([ + check({ colonyId: 'c1' }), + check({ colonyId: 'c1', season: 'SPRING', outcome: 'SURVIVED' }), + check({ colonyId: 'c2' }), + check({ + colonyId: 'c2', + season: 'SPRING', + outcome: 'LOST', + lossCause: 'DEAD_OR_EMPTY', + }), + check({ colonyId: 'c3' }), + check({ colonyId: 'c3', season: 'SPRING', outcome: 'WEAK' }), + check({ colonyId: 'c4' }), + ]); + + expect(summary.total).toMatchObject({ + wintered: 4, + survived: 1, + weak: 1, + lost: 1, + lostByCause: { DEAD_OR_EMPTY: 1, QUEEN_PROBLEM: 0, NATURAL_DISASTER: 0 }, + pendingSpring: 1, + springWithoutAutumn: 0, + lossRatePercent: 25, + isProvisional: true, + }); + }); + + it('never counts a colony without spring check as survived', () => { + const summary = summarizeWinter([check({ colonyId: 'c1' })]); + expect(summary.total.survived).toBe(0); + expect(summary.total.pendingSpring).toBe(1); + expect(summary.total.isProvisional).toBe(true); + }); + + it('keeps spring checks without autumn check out of the loss rate', () => { + const summary = summarizeWinter([ + check({ colonyId: 'c1' }), + check({ colonyId: 'c1', season: 'SPRING', outcome: 'SURVIVED' }), + check({ + colonyId: 'c9', + season: 'SPRING', + outcome: 'LOST', + lossCause: 'NATURAL_DISASTER', + }), + ]); + expect(summary.total).toMatchObject({ + wintered: 1, + lost: 0, + springWithoutAutumn: 1, + lossRatePercent: 0, + isProvisional: false, + }); + }); + + it('reports no loss rate when nothing was wintered', () => { + expect(summarizeWinter([]).total.lossRatePercent).toBeNull(); + }); + + it('groups by apiary, sorted by name', () => { + const summary = summarizeWinter([ + check({ colonyId: 'c1', apiaryId: 'b', apiaryName: 'Wald' }), + check({ colonyId: 'c2', apiaryId: 'a', apiaryName: 'Obstwiese' }), + ]); + expect(summary.apiaries.map((apiary) => apiary.apiaryName)).toEqual([ + 'Obstwiese', + 'Wald', + ]); + }); +}); diff --git a/ApiCore-Backend/src/winter-checks/winter-check-rules.ts b/ApiCore-Backend/src/winter-checks/winter-check-rules.ts new file mode 100644 index 0000000..746fa91 --- /dev/null +++ b/ApiCore-Backend/src/winter-checks/winter-check-rules.ts @@ -0,0 +1,128 @@ +import type { + ColonyLossCause, + WinterCheckSeason, + WinterOutcome, +} from '../../generated/prisma'; + +/** + * Checks from July on belong to the winter starting that year, earlier ones + * to the winter that started the year before (Winter 2026/27 = 2026). The cut + * sits between the latest Auswinterung (spring) and the earliest + * Einwinterung preparations (late summer). + */ +export function winterYearFor(date: Date): number { + const year = date.getUTCFullYear(); + return date.getUTCMonth() >= 6 ? year : year - 1; +} + +export interface WinterCheckShape { + season: WinterCheckSeason; + outcome: WinterOutcome | null; + lossCause: ColonyLossCause | null; +} + +/** German error message, or null when the combination is consistent. */ +export function validateWinterCheck(check: WinterCheckShape): string | null { + if (check.season === 'AUTUMN') { + return check.outcome || check.lossCause + ? 'Eine Einwinterung hat kein Ergebnis und keine Verlustursache - das gehört zur Auswinterung.' + : null; + } + if (!check.outcome) { + return 'Bei der Auswinterung bitte das Ergebnis angeben (überlebt, schwach oder verloren).'; + } + if (check.outcome === 'LOST' && !check.lossCause) { + return 'Bei einem verlorenen Volk bitte die Verlustursache angeben.'; + } + if (check.outcome !== 'LOST' && check.lossCause) { + return 'Eine Verlustursache gibt es nur bei einem verlorenen Volk.'; + } + return null; +} + +export interface WinterSummarySource extends WinterCheckShape { + colonyId: string; + apiaryId: string; + apiaryName: string; +} + +export interface WinterCounts { + /** Colonies with an Einwinterung record - the denominator of the loss rate. */ + wintered: number; + survived: number; + weak: number; + lost: number; + lostByCause: Record; + /** Wintered colonies without Auswinterung yet: unknown, never "survived". */ + pendingSpring: number; + /** Auswinterung without Einwinterung: shown, but outside the loss rate. */ + springWithoutAutumn: number; + /** lost / wintered as in the COLOSS monitoring; null if nothing wintered. */ + lossRatePercent: number | null; + /** True while pendingSpring > 0 - the rate can still rise. */ + isProvisional: boolean; +} + +export interface WinterSummary { + total: WinterCounts; + apiaries: (WinterCounts & { apiaryId: string; apiaryName: string })[]; +} + +function countWinter(checks: WinterSummarySource[]): WinterCounts { + const autumn = new Set( + checks.filter((c) => c.season === 'AUTUMN').map((c) => c.colonyId), + ); + const counts: WinterCounts = { + wintered: autumn.size, + survived: 0, + weak: 0, + lost: 0, + lostByCause: { DEAD_OR_EMPTY: 0, QUEEN_PROBLEM: 0, NATURAL_DISASTER: 0 }, + pendingSpring: 0, + springWithoutAutumn: 0, + lossRatePercent: null, + isProvisional: false, + }; + const springChecked = new Set(); + for (const check of checks) { + if (check.season !== 'SPRING') continue; + springChecked.add(check.colonyId); + if (!autumn.has(check.colonyId)) { + counts.springWithoutAutumn += 1; + continue; + } + if (check.outcome === 'SURVIVED') counts.survived += 1; + if (check.outcome === 'WEAK') counts.weak += 1; + if (check.outcome === 'LOST') { + counts.lost += 1; + if (check.lossCause) counts.lostByCause[check.lossCause] += 1; + } + } + counts.pendingSpring = [...autumn].filter( + (colonyId) => !springChecked.has(colonyId), + ).length; + counts.isProvisional = counts.pendingSpring > 0; + counts.lossRatePercent = + counts.wintered > 0 + ? Math.round((counts.lost / counts.wintered) * 1000) / 10 + : null; + return counts; +} + +export function summarizeWinter(checks: WinterSummarySource[]): WinterSummary { + const byApiary = new Map(); + for (const check of checks) { + byApiary.set(check.apiaryId, [ + ...(byApiary.get(check.apiaryId) ?? []), + check, + ]); + } + const apiaries = [...byApiary.entries()] + .map(([apiaryId, apiaryChecks]) => ({ + apiaryId, + apiaryName: apiaryChecks[0].apiaryName, + ...countWinter(apiaryChecks), + })) + .sort((a, b) => a.apiaryName.localeCompare(b.apiaryName, 'de')); + return { total: countWinter(checks), apiaries }; +} diff --git a/ApiCore-Backend/src/winter-checks/winter-checks.controller.ts b/ApiCore-Backend/src/winter-checks/winter-checks.controller.ts new file mode 100644 index 0000000..7fbd1e0 --- /dev/null +++ b/ApiCore-Backend/src/winter-checks/winter-checks.controller.ts @@ -0,0 +1,67 @@ +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'; +import type { AuthenticatedUser } from '../auth/types/authenticated-user'; +import { Idempotent } from '../common/idempotency/idempotent.decorator'; +import { CreateWinterCheckDto } from './dto/create-winter-check.dto'; +import { UpdateWinterCheckDto } from './dto/update-winter-check.dto'; +import { WinterCheckQueryDto } from './dto/winter-check-query.dto'; +import { winterYearFor } from './winter-check-rules'; +import { WinterChecksService } from './winter-checks.service'; + +@ApiTags('winter-checks') +@ApiBearerAuth() +@Controller('winter-checks') +export class WinterChecksController { + constructor(private readonly winterChecksService: WinterChecksService) {} + + @RequirePermission('winter-checks:read') + @Get() + findAll(@Query() query: WinterCheckQueryDto) { + return this.winterChecksService.findAll(query); + } + + /** Loss figures of one winter; without winterYear the current winter. */ + @RequirePermission('winter-checks:read') + @Get('summary') + summary(@Query() query: WinterCheckQueryDto) { + return this.winterChecksService.summary( + query.winterYear ?? winterYearFor(new Date()), + ); + } + + @RequirePermission('winter-checks:read') + @Get(':id') + findOne(@Param('id') id: string) { + return this.winterChecksService.findById(id); + } + + @RequirePermission('winter-checks:create') + @Idempotent() + @Post() + create( + @Body() dto: CreateWinterCheckDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.winterChecksService.create(dto, actor.id); + } + + @RequirePermission('winter-checks:update') + @Patch(':id') + update( + @Param('id') id: string, + @Body() dto: UpdateWinterCheckDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.winterChecksService.update(id, dto, actor.id); + } +} diff --git a/ApiCore-Backend/src/winter-checks/winter-checks.module.ts b/ApiCore-Backend/src/winter-checks/winter-checks.module.ts new file mode 100644 index 0000000..f83a31b --- /dev/null +++ b/ApiCore-Backend/src/winter-checks/winter-checks.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { ColonyWinterChecksController } from './colony-winter-checks.controller'; +import { WinterChecksController } from './winter-checks.controller'; +import { WinterChecksService } from './winter-checks.service'; + +@Module({ + controllers: [WinterChecksController, ColonyWinterChecksController], + providers: [WinterChecksService], + exports: [WinterChecksService], +}) +export class WinterChecksModule {} diff --git a/ApiCore-Backend/src/winter-checks/winter-checks.service.ts b/ApiCore-Backend/src/winter-checks/winter-checks.service.ts new file mode 100644 index 0000000..02f4c28 --- /dev/null +++ b/ApiCore-Backend/src/winter-checks/winter-checks.service.ts @@ -0,0 +1,277 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditLogService } from '../audit/audit-log.service'; +import { Colony, Prisma, WinterCheck } from '../../generated/prisma'; +import { isLivingColonyStatus } from '../colonies/colony-status'; +import { CreateWinterCheckDto } from './dto/create-winter-check.dto'; +import { UpdateWinterCheckDto } from './dto/update-winter-check.dto'; +import { WinterCheckQueryDto } from './dto/winter-check-query.dto'; +import { + summarizeWinter, + validateWinterCheck, + WinterCheckShape, + WinterSummary, + winterYearFor, +} from './winter-check-rules'; + +const WITH_COLONY = { + colony: { + select: { + id: true, + code: true, + status: true, + apiaryId: true, + apiary: { select: { id: true, name: true } }, + }, + }, +} satisfies Prisma.WinterCheckInclude; + +export type WinterCheckWithColony = Prisma.WinterCheckGetPayload<{ + include: typeof WITH_COLONY; +}>; + +const DUPLICATE_MESSAGE = + 'Für dieses Volk gibt es in diesem Winter schon einen Eintrag dieser Art - bitte den vorhandenen bearbeiten.'; + +@Injectable() +export class WinterChecksService { + constructor( + private readonly prisma: PrismaService, + private readonly auditLog: AuditLogService, + ) {} + + findAll(query: WinterCheckQueryDto): Promise { + return this.prisma.winterCheck.findMany({ + where: { + winterYear: query.winterYear, + colony: query.apiaryId ? { apiaryId: query.apiaryId } : undefined, + }, + include: WITH_COLONY, + orderBy: [{ winterYear: 'desc' }, { date: 'asc' }], + }); + } + + findByColony(colonyId: string): Promise { + return this.prisma.winterCheck.findMany({ + where: { colonyId }, + orderBy: [{ winterYear: 'desc' }, { season: 'asc' }], + }); + } + + async findById(id: string): Promise { + const check = await this.prisma.winterCheck.findUnique({ + where: { id }, + include: WITH_COLONY, + }); + if (!check) { + throw new NotFoundException('Überwinterungs-Eintrag nicht gefunden'); + } + return check; + } + + /** + * Loss figures per winter. Colonies are grouped by the apiary they stand at + * now - a dead colony keeps its last apiary, so the loss stays where it + * happened unless the colony was moved after the winter. + */ + async summary(winterYear: number): Promise { + const checks = await this.prisma.winterCheck.findMany({ + where: { winterYear }, + include: WITH_COLONY, + }); + return summarizeWinter( + checks.map((check) => ({ + colonyId: check.colonyId, + apiaryId: check.colony.apiaryId, + apiaryName: check.colony.apiary.name, + season: check.season, + outcome: check.outcome, + lossCause: check.lossCause, + })), + ); + } + + async create( + dto: CreateWinterCheckDto, + actingUserId: string, + ): Promise { + const { markColonyDeceased, ...fields } = dto; + const colony = await this.findColony(fields.colonyId); + // Wintering a colony that no longer exists is a mistake; documenting the + // Auswinterung of one that already died is not - that is the loss record. + if (fields.season === 'AUTUMN' && !isLivingColonyStatus(colony.status)) { + throw new BadRequestException( + `Das Volk besteht nicht mehr (Status: ${colony.status}) und kann nicht eingewintert werden`, + ); + } + const date = new Date(fields.date); + const data = { + ...fields, + date, + winterYear: fields.winterYear ?? winterYearFor(date), + outcome: fields.outcome ?? null, + lossCause: fields.lossCause ?? null, + }; + this.assertConsistent(data); + + const check = await this.withDuplicateMessage(() => + this.prisma.$transaction(async (tx) => { + const created = await tx.winterCheck.create({ + data: { ...data, userId: actingUserId }, + }); + await this.markDeceasedIfRequested( + tx, + colony, + created, + markColonyDeceased, + actingUserId, + ); + return created; + }), + ); + await this.auditLog.record({ + entityType: 'WinterCheck', + entityId: check.id, + action: 'CREATE', + userId: actingUserId, + afterData: check, + }); + return check; + } + + async update( + id: string, + dto: UpdateWinterCheckDto, + actingUserId: string, + ): Promise { + const { markColonyDeceased, ...fields } = dto; + const { colony: _currentColony, ...before } = await this.findById(id); + void _currentColony; + const colony = await this.findColony(fields.colonyId ?? before.colonyId); + const date = fields.date ? new Date(fields.date) : before.date; + const merged = mergeOutcome(before, fields); + this.assertConsistent(merged); + + const check = await this.withDuplicateMessage(() => + this.prisma.$transaction(async (tx) => { + const updated = await tx.winterCheck.update({ + where: { id }, + data: { + ...fields, + ...merged, + date, + winterYear: + fields.winterYear ?? + (fields.date ? winterYearFor(date) : undefined), + }, + }); + await this.markDeceasedIfRequested( + tx, + colony, + updated, + markColonyDeceased, + actingUserId, + ); + return updated; + }), + ); + await this.auditLog.record({ + entityType: 'WinterCheck', + entityId: id, + action: 'UPDATE', + userId: actingUserId, + beforeData: before, + afterData: check, + }); + return check; + } + + private assertConsistent(check: WinterCheckShape): void { + const problem = validateWinterCheck(check); + if (problem) { + throw new BadRequestException(problem); + } + } + + private async findColony(colonyId: string): Promise { + const colony = await this.prisma.colony.findUnique({ + where: { id: colonyId }, + }); + if (!colony) { + throw new NotFoundException('Volk nicht gefunden'); + } + return colony; + } + + /** + * Only on explicit request, only for a LOST outcome and only while the + * colony is still recorded as living - a loss must never be booked + * implicitly, and an already dissolved or merged colony keeps its status. + */ + private async markDeceasedIfRequested( + tx: Prisma.TransactionClient, + colony: Colony, + check: WinterCheck, + requested: boolean | undefined, + actingUserId: string, + ): Promise { + if (!requested || check.outcome !== 'LOST') return; + if (!isLivingColonyStatus(colony.status)) return; + const after = await tx.colony.update({ + where: { id: colony.id }, + data: { status: 'DECEASED', deactivatedAt: check.date }, + }); + await tx.auditLog.create({ + data: { + entityType: 'Colony', + entityId: colony.id, + action: 'UPDATE', + userId: actingUserId, + beforeData: colony, + afterData: after, + }, + }); + } + + private async withDuplicateMessage(run: () => Promise): Promise { + try { + return await run(); + } catch (error: unknown) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002' + ) { + throw new ConflictException(DUPLICATE_MESSAGE); + } + throw error; + } + } +} + +/** + * Applies an edit to season/outcome/lossCause. Switching to an Einwinterung, + * or away from LOST, clears the fields that no longer apply instead of making + * the caller blank them by hand - an explicitly sent value still wins and is + * then validated. + */ +export function mergeOutcome( + before: WinterCheckShape, + fields: Partial, +): WinterCheckShape { + const season = fields.season ?? before.season; + let outcome = fields.outcome !== undefined ? fields.outcome : before.outcome; + if (season === 'AUTUMN' && fields.outcome === undefined) { + outcome = null; + } + let lossCause = + fields.lossCause !== undefined ? fields.lossCause : before.lossCause; + if (outcome !== 'LOST' && fields.lossCause === undefined) { + lossCause = null; + } + return { season, outcome, lossCause }; +} diff --git a/ApiCore-Backend/test/winter-checks.e2e-spec.ts b/ApiCore-Backend/test/winter-checks.e2e-spec.ts new file mode 100644 index 0000000..9ded02f --- /dev/null +++ b/ApiCore-Backend/test/winter-checks.e2e-spec.ts @@ -0,0 +1,253 @@ +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 WinterCheckBody { + id: string; + colonyId: string; + winterYear: number; + season: string; + outcome: string | null; + lossCause: string | null; +} + +interface WinterSummaryBody { + total: { + wintered: number; + survived: number; + lost: number; + pendingSpring: number; + lossRatePercent: number | null; + isProvisional: boolean; + }; +} + +// A winter far in the past so the summary only sees this spec's records. +const WINTER = 2001; + +describe('Winter checks (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + const stamp = Date.now(); + const beekeeperEmail = `winter-e2e-beekeeper-${stamp}@example.com`; + const readonlyEmail = `winter-e2e-readonly-${stamp}@example.com`; + const password = 'SuperSecret123!'; + let userIds: string[] = []; + let beekeeperToken: string; + let readonlyToken: string; + let apiaryId: string; + let survivorId: string; + let lostId: string; + let pendingId: string; + + const api = () => request(app.getHttpServer()); + const auth = (token: string) => ({ Authorization: `Bearer ${token}` }); + + 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: { connect: { key: 'BEEKEEPER' } }, + }, + }); + const readonly = await prisma.user.create({ + data: { + email: readonlyEmail, + passwordHash, + name: 'Readonly', + role: { connect: { key: 'READONLY' } }, + }, + }); + userIds = [beekeeper.id, readonly.id]; + beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken; + readonlyToken = (await login(app, readonlyEmail, password)).accessToken; + + const apiary = await prisma.apiary.create({ + data: { name: `Winterstand ${stamp}` }, + }); + apiaryId = apiary.id; + const [survivor, lost, pending] = await Promise.all( + ['A', 'B', 'C'].map((suffix) => + prisma.colony.create({ + data: { + code: `W-${stamp}-${suffix}`, + apiaryId, + colonyType: 'PRODUCTION_COLONY', + }, + }), + ), + ); + survivorId = survivor.id; + lostId = lost.id; + pendingId = pending.id; + }); + + afterAll(async () => { + const colonyIds = [survivorId, lostId, pendingId]; + await prisma.winterCheck.deleteMany({ + where: { colonyId: { in: colonyIds } }, + }); + await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } }); + await prisma.apiary.deleteMany({ where: { id: apiaryId } }); + await prisma.refreshToken.deleteMany({ + where: { userId: { in: userIds } }, + }); + await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } }); + await prisma.user.deleteMany({ where: { id: { in: userIds } } }); + await app.close(); + }); + + async function winterIn(colonyId: string): Promise { + const response = await api() + .post('/api/v1/winter-checks') + .set(auth(beekeeperToken)) + .send({ + colonyId, + season: 'AUTUMN', + date: `${WINTER}-10-01`, + occupiedSeams: 8, + feedStockKg: 18, + queenPresent: true, + }) + .expect(201); + return response.body as WinterCheckBody; + } + + it('records an Einwinterung and derives the winter from the date', async () => { + const check = await winterIn(survivorId); + expect(check).toMatchObject({ + winterYear: WINTER, + season: 'AUTUMN', + outcome: null, + }); + await winterIn(lostId); + await winterIn(pendingId); + }); + + it('rejects a second Einwinterung of the same colony in the same winter with 409', () => { + return api() + .post('/api/v1/winter-checks') + .set(auth(beekeeperToken)) + .send({ colonyId: survivorId, season: 'AUTUMN', date: `${WINTER}-10-20` }) + .expect(409); + }); + + it('rejects an Auswinterung without outcome and a loss without cause', async () => { + await api() + .post('/api/v1/winter-checks') + .set(auth(beekeeperToken)) + .send({ colonyId: survivorId, season: 'SPRING', date: '2002-03-20' }) + .expect(400); + await api() + .post('/api/v1/winter-checks') + .set(auth(beekeeperToken)) + .send({ + colonyId: lostId, + season: 'SPRING', + date: '2002-03-20', + outcome: 'LOST', + }) + .expect(400); + }); + + it('books a loss and marks the colony deceased only when asked to', async () => { + await api() + .post('/api/v1/winter-checks') + .set(auth(beekeeperToken)) + .send({ + colonyId: survivorId, + season: 'SPRING', + date: '2002-03-20', + outcome: 'SURVIVED', + }) + .expect(201); + const lost = await api() + .post('/api/v1/winter-checks') + .set(auth(beekeeperToken)) + .send({ + colonyId: lostId, + season: 'SPRING', + date: '2002-03-20', + outcome: 'LOST', + lossCause: 'DEAD_OR_EMPTY', + markColonyDeceased: true, + }) + .expect(201); + expect((lost.body as WinterCheckBody).winterYear).toBe(WINTER); + + const colony = await prisma.colony.findUniqueOrThrow({ + where: { id: lostId }, + }); + expect(colony.status).toBe('DECEASED'); + expect(colony.deactivatedAt?.toISOString()).toBe( + '2002-03-20T00:00:00.000Z', + ); + const survivor = await prisma.colony.findUniqueOrThrow({ + where: { id: survivorId }, + }); + expect(survivor.status).toBe('ACTIVE'); + }); + + it('refuses to winter a colony that has died', () => { + return api() + .post('/api/v1/winter-checks') + .set(auth(beekeeperToken)) + .send({ colonyId: lostId, season: 'AUTUMN', date: '2002-10-01' }) + .expect(400); + }); + + it('summarizes the winter, keeping unchecked colonies out of "survived"', async () => { + const response = await api() + .get(`/api/v1/winter-checks/summary?winterYear=${WINTER}`) + .set(auth(readonlyToken)) + .expect(200); + expect((response.body as WinterSummaryBody).total).toEqual( + expect.objectContaining({ + wintered: 3, + survived: 1, + lost: 1, + pendingSpring: 1, + lossRatePercent: 33.3, + isProvisional: true, + }), + ); + }); + + it('keeps every field editable and clears the cause when the outcome changes', async () => { + const checks = ( + await api() + .get(`/api/v1/colonies/${lostId}/winter-checks`) + .set(auth(beekeeperToken)) + .expect(200) + ).body as WinterCheckBody[]; + const spring = checks.find((check) => check.season === 'SPRING'); + const response = await api() + .patch(`/api/v1/winter-checks/${spring?.id}`) + .set(auth(beekeeperToken)) + .send({ outcome: 'WEAK' }) + .expect(200); + expect(response.body).toMatchObject({ outcome: 'WEAK', lossCause: null }); + }); + + it('lets read-only users view but not record', async () => { + await api() + .get(`/api/v1/winter-checks?winterYear=${WINTER}&apiaryId=${apiaryId}`) + .set(auth(readonlyToken)) + .expect(200); + await api() + .post('/api/v1/winter-checks') + .set(auth(readonlyToken)) + .send({ colonyId: pendingId, season: 'SPRING', date: '2002-03-20' }) + .expect(403); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/sidebar.tsx b/ApiCore-Frontend/src/app/(app)/sidebar.tsx index dbde445..d351959 100644 --- a/ApiCore-Frontend/src/app/(app)/sidebar.tsx +++ b/ApiCore-Frontend/src/app/(app)/sidebar.tsx @@ -20,6 +20,7 @@ import { Package, Scissors, Search, + Snowflake, ShoppingBag, Users, Warehouse, @@ -58,6 +59,12 @@ const NAV_GROUPS: NavGroup[] = [ { label: "Bienenstände", href: "/apiaries", icon: MapPin }, { label: "Völker", href: "/colonies", icon: Layers }, { label: "Königinnen", href: "/queens", icon: Crown }, + { + label: "Überwinterung", + href: "/winter", + icon: Snowflake, + permission: "winter-checks:read", + }, ], }, { diff --git a/ApiCore-Frontend/src/app/(app)/winter/page.tsx b/ApiCore-Frontend/src/app/(app)/winter/page.tsx new file mode 100644 index 0000000..68377aa --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/winter/page.tsx @@ -0,0 +1,261 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { ChevronLeft, ChevronRight, Snowflake } from "lucide-react"; +import { useAuth } from "@/lib/auth/auth-context"; +import { fetchApiaries } from "@/lib/apiaries/api"; +import { fetchColonies } from "@/lib/colonies/api"; +import { fetchWinterChecks, fetchWinterSummary } from "@/lib/winter-checks/api"; +import { LOSS_CAUSE_LABELS, WINTER_OUTCOME_LABELS } from "@/lib/winter-checks/labels"; +import { ColonyLossCause, WinterCheckWithColony, WinterCounts } from "@/lib/winter-checks/types"; +import { buildWinterRows, WinterRow, winterLabel, winterYearFor } from "@/lib/winter-checks/winter"; +import { PageHeader } from "@/components/page-header"; +import { EmptyState } from "@/components/empty-state"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { WinterCheckDialog, WinterCheckTarget } from "./winter-check-dialog"; + +const DATE = new Intl.DateTimeFormat("de-DE", { day: "2-digit", month: "2-digit" }); + +function autumnText(check: WinterCheckWithColony): string { + const parts = [DATE.format(new Date(check.date))]; + if (check.occupiedSeams !== null) parts.push(`${check.occupiedSeams} Gassen`); + if (check.feedStockKg !== null) { + parts.push(`${check.feedStockKg.toLocaleString("de-DE")} kg Futter`); + } + return parts.join(" · "); +} + +function SummaryCards({ counts }: { counts: WinterCounts }) { + // Before the first Auswinterung a rate of "0 %" would read as "no losses". + const hasSpringResults = counts.survived + counts.weak + counts.lost > 0; + const causes = (Object.keys(counts.lostByCause) as ColonyLossCause[]).filter( + (cause) => counts.lostByCause[cause] > 0, + ); + return ( +
+ + + + `${counts.lostByCause[cause]}× ${LOSS_CAUSE_LABELS[cause]}`).join(", ")} + /> + 0 + ? `${counts.pendingSpring} Volk/Völker noch nicht ausgewintert` + : undefined + } + /> +
+ ); +} + +function Stat({ label, value, hint }: { label: string; value: number | string; hint?: string }) { + return ( +
+

{label}

+

{value}

+ {hint ?

{hint}

: null} +
+ ); +} + +function RowActions({ + row, + canCreate, + canUpdate, + isSpringDue, + onOpen, +}: { + row: WinterRow; + canCreate: boolean; + canUpdate: boolean; + /** The winter is over (January on) - Auswinterung also without Einwinterung record. */ + isSpringDue: boolean; + onOpen: (season: "AUTUMN" | "SPRING", existing: WinterCheckWithColony | null) => void; +}) { + return ( +
+ {row.autumn ? ( + + ) : row.isLiving && canCreate ? ( + + ) : ( + Nicht eingewintert + )} + {row.spring?.outcome ? ( + + ) : canCreate && (row.autumn || isSpringDue) ? ( + + ) : null} +
+ ); +} + +export default function WinterPage() { + const { hasPermission } = useAuth(); + const [winterYear, setWinterYear] = useState(() => winterYearFor(new Date())); + const [target, setTarget] = useState(null); + const canCreate = hasPermission("winter-checks:create"); + const canUpdate = hasPermission("winter-checks:update"); + + const checksQuery = useQuery({ + queryKey: ["winter-checks", winterYear], + queryFn: () => fetchWinterChecks(winterYear), + }); + const summaryQuery = useQuery({ + queryKey: ["winter-checks", "summary", winterYear], + queryFn: () => fetchWinterSummary(winterYear), + }); + const coloniesQuery = useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() }); + const apiariesQuery = useQuery({ queryKey: ["apiaries"], queryFn: () => fetchApiaries() }); + + const groups = useMemo( + () => + buildWinterRows( + coloniesQuery.data ?? [], + checksQuery.data ?? [], + new Map((apiariesQuery.data ?? []).map((apiary) => [apiary.id, apiary.name])), + ), + [coloniesQuery.data, checksQuery.data, apiariesQuery.data], + ); + // One action per row until the colony is wintered: from January of the + // following year on, Auswinterung is offered for every colony. + const isSpringDue = new Date() >= new Date(winterYear + 1, 0, 1); + const openAutumn = groups.reduce( + (sum, group) => sum + group.rows.filter((row) => row.isLiving && !row.autumn).length, + 0, + ); + + function open(row: WinterRow, season: "AUTUMN" | "SPRING", existing: WinterCheckWithColony | null) { + setTarget({ + colonyId: row.colonyId, + code: row.code, + isLiving: row.isLiving, + season, + winterYear, + existing, + }); + } + + const isLoading = checksQuery.isLoading || coloniesQuery.isLoading; + const isError = checksQuery.isError || coloniesQuery.isError; + + return ( +
+ + + {winterLabel(winterYear)} + +
+ } + /> + + {openAutumn > 0 && canCreate && !isSpringDue ? ( +

+ Als Nächstes: {openAutumn} Volk/Völker noch nicht + eingewintert. +

+ ) : null} + + {summaryQuery.data ? : null} + + {isError ? ( +

+ Daten konnten nicht geladen werden. Bitte die Seite neu laden. +

+ ) : isLoading ? ( +

Wird geladen…

+ ) : groups.length === 0 ? ( + + ) : ( + groups.map((group) => ( + + + {group.apiaryName} + + +
    + {group.rows.map((row) => ( +
  • + + {row.code} + {!row.isLiving ? ( + (besteht nicht mehr) + ) : null} + + open(row, season, existing)} + /> +
  • + ))} +
+
+
+ )) + )} + + {target ? setTarget(null)} /> : null} + + ); +} diff --git a/ApiCore-Frontend/src/app/(app)/winter/winter-check-dialog.tsx b/ApiCore-Frontend/src/app/(app)/winter/winter-check-dialog.tsx new file mode 100644 index 0000000..670a1fb --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/winter/winter-check-dialog.tsx @@ -0,0 +1,269 @@ +"use client"; + +import { FormEvent, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { ApiError } from "@/lib/auth/api-client"; +import { newIdempotencyKey } from "@/lib/offline/idempotency"; +import { createWinterCheck, updateWinterCheck } from "@/lib/winter-checks/api"; +import { LOSS_CAUSE_LABELS, WINTER_OUTCOME_LABELS } from "@/lib/winter-checks/labels"; +import { + ColonyLossCause, + WinterCheck, + WinterCheckInput, + WinterCheckSeason, + WinterOutcome, +} from "@/lib/winter-checks/types"; +import { winterLabel } from "@/lib/winter-checks/winter"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +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 } from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; + +export interface WinterCheckTarget { + colonyId: string; + code: string; + isLiving: boolean; + season: WinterCheckSeason; + winterYear: number; + existing: WinterCheck | null; +} + +const QUEEN_OPTIONS = { yes: "Ja", no: "Nein", unknown: "Nicht geprüft" } as const; +type QueenOption = keyof typeof QUEEN_OPTIONS; + +function queenOption(value: boolean | null | undefined): QueenOption { + if (value === true) return "yes"; + if (value === false) return "no"; + return "unknown"; +} + +function toNumber(value: string): number | undefined { + const trimmed = value.trim().replace(",", "."); + return trimmed === "" ? undefined : Number(trimmed); +} + +const OUTCOMES: WinterOutcome[] = ["SURVIVED", "WEAK", "LOST"]; + +export function WinterCheckDialog({ + target, + onClose, +}: { + target: WinterCheckTarget; + onClose: () => void; +}) { + const queryClient = useQueryClient(); + const existing = target.existing; + const isSpring = target.season === "SPRING"; + const [date, setDate] = useState( + existing?.date.slice(0, 10) ?? new Date().toISOString().slice(0, 10), + ); + const [occupiedSeams, setOccupiedSeams] = useState(existing?.occupiedSeams?.toString() ?? ""); + const [feedStockKg, setFeedStockKg] = useState(existing?.feedStockKg?.toString() ?? ""); + const [queen, setQueen] = useState(queenOption(existing?.queenPresent)); + const [outcome, setOutcome] = useState(existing?.outcome ?? null); + const [lossCause, setLossCause] = useState(existing?.lossCause ?? ""); + const [markDeceased, setMarkDeceased] = useState(target.isLiving); + const [notes, setNotes] = useState(existing?.notes ?? ""); + const [idempotencyKey] = useState(newIdempotencyKey); + + const isLost = outcome === "LOST"; + const missing = isSpring && (!outcome || (isLost && !lossCause)); + + const mutation = useMutation({ + mutationFn: () => { + const input: WinterCheckInput = { + colonyId: target.colonyId, + season: target.season, + winterYear: target.winterYear, + date: new Date(date).toISOString(), + occupiedSeams: toNumber(occupiedSeams), + feedStockKg: isSpring ? undefined : toNumber(feedStockKg), + queenPresent: queen === "unknown" ? undefined : queen === "yes", + outcome: outcome ?? undefined, + lossCause: isLost && lossCause ? lossCause : undefined, + notes: notes.trim() || undefined, + markColonyDeceased: isLost && target.isLiving ? markDeceased : undefined, + }; + return existing + ? updateWinterCheck(existing.id, input) + : createWinterCheck(input, { idempotencyKey }); + }, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["winter-checks"] }); + if (isLost && markDeceased) { + void queryClient.invalidateQueries({ queryKey: ["colonies"] }); + } + toast.success(`${target.code}: gespeichert`); + onClose(); + }, + onError: (error) => + toast.error( + error instanceof ApiError ? error.message : "Speichern fehlgeschlagen. Bitte noch einmal versuchen.", + ), + }); + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + if (missing) return; + mutation.mutate(); + } + + const title = `${target.code} ${isSpring ? "auswintern" : "einwintern"}`; + + return ( + (open ? undefined : onClose())}> + + + {title} +

{winterLabel(target.winterYear)}

+
+
+ {isSpring ? ( +
+ Wie ist das Volk durch den Winter gekommen? +
+ {OUTCOMES.map((value) => ( + + ))} +
+
+ ) : null} + + {isLost ? ( +
+
+ + +

+ Kategorien wie im COLOSS-Monitoring. Vermutete Gründe (z. B. Varroa) gern in die Notiz. +

+
+ {target.isLiving ? ( +
+ setMarkDeceased(checked === true)} + /> + +
+ ) : null} +
+ ) : null} + +
+ + setDate(event.target.value)} + required + /> +
+ + {!isLost ? ( +
+
+ + setOccupiedSeams(event.target.value)} + /> +
+ {!isSpring ? ( +
+ + setFeedStockKg(event.target.value)} + /> +
+ ) : null} +
+ ) : null} + + {!isLost ? ( +
+ + +
+ ) : null} + +
+ +