Compare commits
35
Commits
No files matched your search
+6
-5
@@ -98,6 +98,7 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
|
||||
7. **Dokumentenspeicher als lokales Volume (Modul 29)**: `DocumentsModule` speichert Uploads unterhalb von `DOCUMENTS_STORAGE_PATH` auf dem lokalen Dateisystem des Backend-Pods, keine Objektspeicher-Anbindung (S3-kompatibel o.ä.) im MVP/v1.x. Der Pfad ist über die ConfigMap austauschbar, wie in Abschnitt 27 des Lastenhefts vorgesehen. Wichtig für den Betrieb: das aktuelle `deploy/k8s`-Deployment läuft mit `replicas: 2` ohne geteiltes Volume – für konsistente Uploads über beide Pods hinweg muss vor Produktivbetrieb mit Dokumenten-Feature ein `ReadWriteMany`-PVC (z.B. NFS- oder EFS-basiert) gemountet und `DOCUMENTS_STORAGE_PATH` darauf gesetzt werden, sonst sieht nur der Pod, der die Datei entgegengenommen hat, sie auch beim Download wieder.
|
||||
8. **Entra ID SSO ohne Auto-Provisioning**: `POST/GET /api/v1/auth/sso/*` implementiert Authorization Code + PKCE gegen Entra ID über `openid-client`. Der E-Mail-Claim aus dem ID-Token wird ausschließlich gegen einen bereits existierenden, aktiven `User`-Datensatz gematcht – es wird kein neuer Benutzer automatisch angelegt (Just-in-Time-Provisioning), passend zum bestehenden Modell, dass Admins Benutzer manuell anlegen. Eine erfolgreiche SSO-Anmeldung überspringt die App-eigene TOTP-MFA-Abfrage, da der Identity Provider bereits eine eigene (typischerweise stärkere) Authentifizierung durchgesetzt hat. Der PKCE-`code_verifier` wird kurzlebig (5 min) in einem httpOnly-`SameSite=Lax`-Cookie zwischengespeichert statt serverseitig, damit der Flow ohne Sticky-Sessions über mehrere Backend-Replicas hinweg funktioniert; der Rückweg vom Callback zum Frontend nutzt ein kurzlebiges (60 s), zweckgebundenes JWT statt des echten Access-Tokens in der Redirect-URL. `OIDC_ISSUER_URL`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`/`OIDC_REDIRECT_URI` kommen ausschließlich aus Environment-Variablen; fehlen sie, meldet `GET /api/v1/auth/sso/status` `enabled: false` und die Login-Seite blendet den "Mit Microsoft anmelden"-Button aus, statt beim Start zu scheitern.
|
||||
9. **M365-Kalender-Synchronisation als einseitiger, asynchroner Push**: Abschnitt 4.25 verlangt nur einen internen Betriebskalender, keine externe Anbindung – diese Funktion ist eine reine Erweiterung ohne Lastenheft-Vorgabe. Umgesetzt als `CalendarSyncModule` (`ApiCore-Backend/src/calendar-sync/`): App-only Graph-API-Zugriff per Client-Credentials-Flow (`GraphClientService`) statt delegierter Anmeldung, da die Synchronisation unbeaufsichtigt im Hintergrund laufen muss und nicht an eine interaktive Benutzersitzung gebunden sein darf. Nutzt bewusst dieselbe Entra-App-Registrierung wie das SSO-Login (`OIDC_ISSUER_URL`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`, ausschließlich Env-Variablen) statt einer zweiten, separaten App-Registrierung – ein Enterprise-App-Objekt im Tenant genügt, sofern es zusätzlich zu den delegierten Login-Scopes auch die Application-Permission „Calendars.ReadWrite“ (Admin-Consent) erhält; die Tenant-ID wird dafür aus dem Pfad von `OIDC_ISSUER_URL` abgeleitet, statt eine weitere Konfigurationsvariable zu benötigen. Die Übertragung ist bewusst nur einseitig (ApiCore → M365-Gruppenkalender, adressiert über `groupId`); Änderungen, die direkt im M365-Kalender vorgenommen werden, fließen nicht zurück. Statt eines synchronen Push im Request-Pfad (wie beim Webhooks-Modul) nutzt `CalendarSyncOutboxEntry` ein Outbox-Muster mit minütlichem `@Cron`-Worker: Graph-API-Aufrufe sind zu langsam/fehleranfällig (Token-Refresh, Drosselung, transiente 5xx), um eine Durchsicht-/Aufgaben-Erstellung darauf warten zu lassen; fehlgeschlagene Versuche werden bis zu 5-mal wiederholt, danach bleibt der Eintrag `FAILED` bis zu einem manuellen "Jetzt synchronisieren" oder einer erneuten Änderung des Quelldatensatzes. Pro Quelltyp (Aufgabe/Durchsicht/Behandlung/Fütterung/Ernte/Königinnen-Zuordnung) ist die Synchronisation einzeln über die GUI (Verwaltung > Automatisierung > Kalender-Sync) abschaltbar; Kalendereinträge werden als ganztägige Graph-Events (`isAllDay: true`) angelegt, um Zeitzonen-/Uhrzeit-Mehrdeutigkeiten zu vermeiden, da die zugrundeliegenden Fachdaten selbst keine Uhrzeit führen.
|
||||
10. **Wartezeit manuell statt aus Wirkstoff-Datenbank abgeleitet**: `Treatment.withdrawalPeriodDays` wird pro Behandlung von Hand eingetragen (laut Packungsbeilage des verwendeten Mittels), statt automatisch aus Produktname/Wirkstoff hergeleitet zu werden. Eine hartkodierte Zuordnung Produkt→Wartezeit wäre eine faktische, rechtlich relevante Behauptung über Tierarzneimittel-Zulassungen, die im Code weder aktuell gehalten noch verlässlich verifiziert werden kann; die Verantwortung für den korrekten Wert bleibt beim Imker. Die App warnt in der Ernte-Erfassung nur, wenn ein Wert hinterlegt wurde und dessen Fenster (`endDate ?? date` + `withdrawalPeriodDays`) noch nicht abgelaufen ist – sie blockiert die Ernte nicht hart, da Ernte-Datensätze auch nachträglich für bereits erfolgte, reale Ernten erfasst werden.
|
||||
|
||||
## 7. Kubernetes
|
||||
|
||||
@@ -111,10 +112,10 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
|
||||
|
||||
**Erster Admin-Zugang:** `prisma/seed.ts` legt beim allerersten Start (leere `User`-Tabelle) einen Administrator an (`SEED_ADMIN_EMAIL`/`SEED_ADMIN_PASSWORD`, Default `[email protected]`/`ChangeMe123!`) und gibt URL (`FRONTEND_ORIGIN`), Benutzername und Passwort einmalig im Container-Log aus. Existiert der User schon (auch deaktiviert), passiert nichts – kein erneutes Log, kein erneutes Anlegen, damit spätere Passwortänderungen nicht durch veraltete Logs verwirrend überschrieben wirken und Zugangsdaten nicht bei jedem Neustart in die Logs wandern. Ersetzen: neuen Admin über `/settings/users` anlegen, dann den alten deaktivieren (Löschen gibt es nicht, siehe "Historie statt Löschen") – die Lockout-Sperre in `UsersService.assertNotLastSystemRoleUser` verhindert ohnehin, den letzten aktiven Admin zu deaktivieren.
|
||||
|
||||
**Datensicherung:** Seit der Entscheidung, PostgreSQL selbst im Cluster zu betreiben (siehe CLAUDE.md), liegt die Backup-Verantwortung bei diesem Projekt, nicht mehr bei einem externen Cluster-/DBA-Betrieb. Aktuell **noch nicht umgesetzt** (offener Punkt, siehe TASKS.md) – empfohlene Strategie:
|
||||
**Datensicherung:** Seit der Entscheidung, PostgreSQL selbst im Cluster zu betreiben (siehe CLAUDE.md), liegt die Backup-Verantwortung bei diesem Projekt, nicht mehr bei einem externen Cluster-/DBA-Betrieb. Umgesetzt als anwendungsseitiges `BackupModule` (`ApiCore-Backend/src/backup/`) statt der ursprünglich hier vorgesehenen reinen `pg_dump`/PITR-Strategie:
|
||||
|
||||
- Tägliches logisches Backup via `pg_dump -Fc` (custom format, komprimiert, für `pg_restore` geeignet), z. B. als Kubernetes-`CronJob` gegen den `apicore-postgres`-Service, Ablage auf einem separaten PVC oder Objektspeicher außerhalb des Postgres-eigenen Volumes.
|
||||
- Kontinuierliches WAL-Archiving/PITR (Point-in-Time Recovery), falls die Storage-Class/das Backup-Tooling das unterstützt, für Wiederherstellung auf einen beliebigen Zeitpunkt zwischen den täglichen Dumps.
|
||||
- Restore-Test: `pg_restore` eines Dumps in eine isolierte Datenbank, danach `npx prisma migrate deploy` zur Kontrolle, dass der Migrationsstand konsistent ist (`prisma migrate status`).
|
||||
- **Export/Import:** `BackupService.exportAll()`/`.importAll()` sichert wirklich alle Modelle (Betriebsdaten, Benutzer/Rollen/Rechte, Audit-Log, hochgeladene Dokumente inkl. Dateiinhalt als Base64) als eine versionierte JSON-Datei; Import ist upsert-basiert (Rollen/Berechtigungen über `key`/`resource+action` statt roher `id`, damit ein Restore auch in ein anderes Deployment funktioniert) und läuft in einer einzigen `$transaction`.
|
||||
- **Automatischer Zeitplan:** `BackupSchedulerService` registriert einen `@nestjs/schedule`-Cron-Job, der die Bundle-Datei periodisch auf `BACKUP_STORAGE_PATH` schreibt und alte Stände nach `BACKUP_RETENTION_COUNT` rotiert. Pfad/Zeitplan/Aufbewahrung sind sowohl per Env-Variable als auch über die GUI (Verwaltung > Backup) konfigurierbar – eine gesetzte Env-Variable hat dabei immer Vorrang vor dem GUI-Wert. Bei `replicas: 2` verhindert ein Datei-Mtime-Guard (kein DB-Lock nötig), dass beide Pods im selben Tick doppelt sichern; ohne gesetzten Pfad bleibt der Zeitplan inaktiv, manueller Export/Import über die UI funktioniert davon unabhängig immer.
|
||||
- **Restore:** Über `POST /backup/import` (bzw. den Import-Dialog unter Verwaltung > Backup) eine zuvor exportierte Bundle-Datei einspielen.
|
||||
|
||||
**Restore-Ablauf (Kurzfassung):** 1) Zielinstanz/-datenbank bereitstellen. 2) `pg_restore` (oder WAL-Replay bis zum Zielzeitpunkt bei PITR). 3) `npx prisma migrate status` prüfen. 4) `npx prisma migrate deploy` falls seit dem Dump neue Migrationen hinzugekommen sind. 5) `GET /health/ready` gegen die wiederhergestellte Instanz aufrufen, um DB-Erreichbarkeit zu bestätigen, bevor Verkehr umgeschaltet wird.
|
||||
Ein rohes `pg_dump`/PITR-Backup auf Infrastrukturebene bleibt als zusätzliche, komplementäre Absicherung für den Katastrophenfall (z. B. korrupte Datenbankdateien, nicht nur fachliche Daten) sinnvoll, ist aber nicht mehr der primäre, hier dokumentierte Mechanismus – die in-App-Lösung deckt den fachlichen Anwendungsfall "Daten sichern/übertragen" bereits vollständig ab.
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Treatment" ADD COLUMN "withdrawalPeriodDays" INTEGER;
|
||||
@@ -584,6 +584,7 @@ model Treatment {
|
||||
reason String?
|
||||
batchNumber String?
|
||||
notes String?
|
||||
withdrawalPeriodDays Int?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([colonyId, date])
|
||||
|
||||
@@ -30,11 +30,172 @@ function printLoginBanner(email: string, password: string): void {
|
||||
console.log(line);
|
||||
}
|
||||
|
||||
// Duplicated from src/task-templates/recurrence.ts rather than imported:
|
||||
// tsconfig.seed.json scopes this build to rootDir "./prisma" only (its own
|
||||
// isolated compile, separate from the main Nest build), so a cross-rootDir
|
||||
// import would fail to compile. Only the YEARLY case is needed here.
|
||||
function nextYearlyOccurrenceUtc(
|
||||
month: number,
|
||||
dayOfMonth: number,
|
||||
from: Date,
|
||||
): Date {
|
||||
const year = from.getUTCFullYear();
|
||||
const clampedDay = (y: number): number =>
|
||||
Math.min(dayOfMonth, new Date(Date.UTC(y, month, 0)).getUTCDate());
|
||||
const today = new Date(
|
||||
Date.UTC(from.getUTCFullYear(), from.getUTCMonth(), from.getUTCDate()),
|
||||
);
|
||||
const candidate = new Date(Date.UTC(year, month - 1, clampedDay(year)));
|
||||
if (candidate.getTime() >= today.getTime()) {
|
||||
return candidate;
|
||||
}
|
||||
return new Date(Date.UTC(year + 1, month - 1, clampedDay(year + 1)));
|
||||
}
|
||||
|
||||
interface DefaultTaskTemplate {
|
||||
title: string;
|
||||
description: string;
|
||||
priority: 'LOW' | 'NORMAL' | 'HIGH' | 'URGENT';
|
||||
month: number;
|
||||
dayOfMonth: number;
|
||||
}
|
||||
|
||||
// The standard German-beekeeping annual cycle, one flagship task per month -
|
||||
// gives a new deployment a ready-made starter calendar instead of a
|
||||
// completely empty task-template list. Admins can edit/deactivate any of
|
||||
// these afterward; this only ever runs once (see the count() guard below).
|
||||
const DEFAULT_TASK_TEMPLATES: DefaultTaskTemplate[] = [
|
||||
{
|
||||
title: 'Wintersitz kontrollieren',
|
||||
description:
|
||||
'Ruhige Kontrolle von außen: Flugloch frei, Gewicht per Anheben schätzen, auf Mäuse-/Spechtschäden achten. Beute möglichst nicht öffnen.',
|
||||
priority: 'NORMAL',
|
||||
month: 1,
|
||||
dayOfMonth: 15,
|
||||
},
|
||||
{
|
||||
title: 'Gemülldiagnose',
|
||||
description:
|
||||
'Windel auswerten: natürlicher Varroa-Totenfall, Bienensitz und Lage der Wintertraube anhand des Gemülls einschätzen.',
|
||||
priority: 'NORMAL',
|
||||
month: 2,
|
||||
dayOfMonth: 15,
|
||||
},
|
||||
{
|
||||
title: 'Frühjahrsdurchsicht',
|
||||
description:
|
||||
'Erste Innendurchsicht bei ca. 15°C und ruhigem Wetter: Brutnest, Futtervorrat, Volksstärke und Weiselrichtigkeit prüfen.',
|
||||
priority: 'HIGH',
|
||||
month: 3,
|
||||
dayOfMonth: 15,
|
||||
},
|
||||
{
|
||||
title: 'Volk erweitern',
|
||||
description:
|
||||
'Zargen/Rähmchen nach Volksstärke erweitern, Baurahmen einhängen, Platz für die Frühtracht schaffen.',
|
||||
priority: 'NORMAL',
|
||||
month: 4,
|
||||
dayOfMonth: 1,
|
||||
},
|
||||
{
|
||||
title: 'Schwarmkontrolle beginnen',
|
||||
description:
|
||||
'Wöchentlich auf Weiselzellen kontrollieren, bis zum Ende der Schwarmzeit fortführen. Maßnahmen bei Bedarf: Ableger bilden, Zellen brechen.',
|
||||
priority: 'HIGH',
|
||||
month: 5,
|
||||
dayOfMonth: 1,
|
||||
},
|
||||
{
|
||||
title: 'Honigernte vorbereiten',
|
||||
description:
|
||||
'Reifegrad der Frühtracht prüfen (Spritzprobe), Schleuderplan und Gerätereinigung vorbereiten.',
|
||||
priority: 'NORMAL',
|
||||
month: 6,
|
||||
dayOfMonth: 15,
|
||||
},
|
||||
{
|
||||
title: 'Varroa-Kontrolle nach der Ernte',
|
||||
description:
|
||||
'Milbenbefall messen (z. B. Puderzuckermethode oder natürlicher Totenfall) und Behandlung planen, sobald die Honigräume abgeräumt sind.',
|
||||
priority: 'HIGH',
|
||||
month: 7,
|
||||
dayOfMonth: 15,
|
||||
},
|
||||
{
|
||||
title: 'Einfütterung beginnen',
|
||||
description:
|
||||
'Wintervorrat auffüttern (Zielgewicht je nach Beutensystem), Futteraufnahme nach ein bis zwei Wochen kontrollieren.',
|
||||
priority: 'HIGH',
|
||||
month: 8,
|
||||
dayOfMonth: 1,
|
||||
},
|
||||
{
|
||||
title: 'Varroa-Restentmilbung / Winterfestigkeit prüfen',
|
||||
description:
|
||||
'Zweite Varroabehandlung durchführen, Volksstärke und Weiselrichtigkeit für den Winter beurteilen.',
|
||||
priority: 'HIGH',
|
||||
month: 9,
|
||||
dayOfMonth: 15,
|
||||
},
|
||||
{
|
||||
title: 'Fütterung abschließen, Mäusegitter anbringen',
|
||||
description:
|
||||
'Letzte Futtergabe, Fluglochkeile bzw. Mäusegitter montieren, Bienenstand winterfest machen.',
|
||||
priority: 'NORMAL',
|
||||
month: 10,
|
||||
dayOfMonth: 1,
|
||||
},
|
||||
{
|
||||
title: 'Winterbehandlung (Oxalsäure)',
|
||||
description:
|
||||
'Brutfreiheit abwarten und Träufel- oder Sprühbehandlung gegen Varroa durchführen.',
|
||||
priority: 'HIGH',
|
||||
month: 11,
|
||||
dayOfMonth: 15,
|
||||
},
|
||||
{
|
||||
title: 'Jahresrückblick und Gerätewartung',
|
||||
description:
|
||||
'Saison auswerten, Zargen und Rähmchen reinigen/reparieren, Material für die kommende Saison planen.',
|
||||
priority: 'LOW',
|
||||
month: 12,
|
||||
dayOfMonth: 15,
|
||||
},
|
||||
];
|
||||
|
||||
async function seedDefaultTaskTemplates(prisma: PrismaClient): Promise<void> {
|
||||
const existingCount = await prisma.taskTemplate.count();
|
||||
if (existingCount > 0) {
|
||||
return;
|
||||
}
|
||||
const now = new Date();
|
||||
await prisma.taskTemplate.createMany({
|
||||
data: DEFAULT_TASK_TEMPLATES.map((template) => ({
|
||||
title: template.title,
|
||||
description: template.description,
|
||||
priority: template.priority,
|
||||
recurrenceType: 'YEARLY',
|
||||
recurrenceMonth: template.month,
|
||||
recurrenceDayOfMonth: template.dayOfMonth,
|
||||
nextDueDate: nextYearlyOccurrenceUtc(
|
||||
template.month,
|
||||
template.dayOfMonth,
|
||||
now,
|
||||
),
|
||||
})),
|
||||
});
|
||||
console.log(
|
||||
`Seed: ${DEFAULT_TASK_TEMPLATES.length} Standard-Aufgabenvorlagen angelegt.`,
|
||||
);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const prisma = new PrismaClient({
|
||||
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
|
||||
});
|
||||
|
||||
await seedDefaultTaskTemplates(prisma);
|
||||
|
||||
const email = process.env.SEED_ADMIN_EMAIL ?? DEFAULT_ADMIN_EMAIL;
|
||||
const password = process.env.SEED_ADMIN_PASSWORD ?? generateRandomPassword();
|
||||
const existing = await prisma.user.findUnique({ where: { email } });
|
||||
|
||||
@@ -12,6 +12,7 @@ describe('ApiariesService', () => {
|
||||
create: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
colony: { count: jest.Mock };
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
|
||||
@@ -30,6 +31,7 @@ describe('ApiariesService', () => {
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
colony: { count: jest.fn().mockResolvedValue(0) },
|
||||
};
|
||||
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||
service = new ApiariesService(
|
||||
@@ -76,6 +78,16 @@ describe('ApiariesService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('deactivate throws ConflictException when active colonies remain', async () => {
|
||||
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
|
||||
prisma.colony.count.mockResolvedValue(2);
|
||||
|
||||
await expect(
|
||||
service.deactivate('apiary-1', 'user-1'),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(prisma.apiary.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reactivate throws ConflictException when already active', async () => {
|
||||
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
|
||||
await expect(
|
||||
|
||||
@@ -76,6 +76,19 @@ export class ApiariesService {
|
||||
|
||||
async deactivate(id: string, actingUserId: string): Promise<Apiary> {
|
||||
const before = await this.findById(id);
|
||||
// Colony.apiaryId is a plain FK, not enforced against apiary status -
|
||||
// without this guard a colony can silently keep pointing at an
|
||||
// inactive apiary (still shows ACTIVE, still countable, but its
|
||||
// "location" has vanished from the active apiary list). Move the
|
||||
// colonies elsewhere or dissolve them first instead.
|
||||
const activeColonyCount = await this.prisma.colony.count({
|
||||
where: { apiaryId: id, status: 'ACTIVE' },
|
||||
});
|
||||
if (activeColonyCount > 0) {
|
||||
throw new ConflictException(
|
||||
`Der Bienenstand hat noch ${activeColonyCount} aktive(s) Volk/Völker - zuerst verlegen oder auflösen`,
|
||||
);
|
||||
}
|
||||
const apiary = await this.prisma.apiary.update({
|
||||
where: { id },
|
||||
data: { status: 'INACTIVE', deactivatedAt: new Date() },
|
||||
|
||||
@@ -191,6 +191,7 @@ export const PERMISSION_CATALOG = [
|
||||
['read', 'Ansehen'],
|
||||
['create', 'Anlegen'],
|
||||
['update', 'Bearbeiten'],
|
||||
['delete', 'Löschen'],
|
||||
],
|
||||
1600,
|
||||
),
|
||||
|
||||
@@ -28,4 +28,11 @@ describe('formatGeneratedNumber', () => {
|
||||
|
||||
expect(result).toBe('FIXED-NUMBER');
|
||||
});
|
||||
|
||||
it('clamps a pathologically large {SEQ:N} width instead of allocating it', () => {
|
||||
const result = formatGeneratedNumber('L-{SEQ:999999999}', 7, date);
|
||||
|
||||
expect(result).toBe(`L-${'0'.repeat(19)}7`);
|
||||
expect(result.length).toBe(22);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,10 @@
|
||||
// Far beyond any realistic business use (sequence numbers are a handful of
|
||||
// digits) but small enough that padStart can never be tricked into
|
||||
// allocating a pathologically large string - {SEQ:999999999} in a
|
||||
// carelessly typed format would otherwise try to build a ~1GB string on
|
||||
// every single batch/bottling creation from then on.
|
||||
const MAX_SEQUENCE_PAD_WIDTH = 20;
|
||||
|
||||
// Placeholders: {YYYY} 4-digit year, {YY} 2-digit year, {MM} month (01-12),
|
||||
// {DD} day (01-31), {SEQ} running sequence unpadded, {SEQ:N} sequence
|
||||
// zero-padded to N digits. Example matching the lastenheft's own sample
|
||||
@@ -18,7 +25,10 @@ export function formatGeneratedNumber(
|
||||
.replace(/\{MM\}/g, mm)
|
||||
.replace(/\{DD\}/g, dd)
|
||||
.replace(/\{SEQ:(\d+)\}/g, (_match, width: string) =>
|
||||
String(sequence).padStart(Number(width), '0'),
|
||||
String(sequence).padStart(
|
||||
Math.min(Number(width), MAX_SEQUENCE_PAD_WIDTH),
|
||||
'0',
|
||||
),
|
||||
)
|
||||
.replace(/\{SEQ\}/g, String(sequence));
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
@@ -51,4 +52,10 @@ export class BottlingController {
|
||||
) {
|
||||
return this.bottlingService.update(id, dto, actor.id);
|
||||
}
|
||||
|
||||
@RequirePermission('bottling:delete')
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
|
||||
return this.bottlingService.remove(id, actor.id);
|
||||
}
|
||||
}
|
||||
@@ -20,13 +20,18 @@ describe('BottlingService', () => {
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
let tx: {
|
||||
bottlingBatch: { create: jest.Mock };
|
||||
bottlingBatch: { create: jest.Mock; update: jest.Mock; delete: jest.Mock };
|
||||
honeyBatch: { update: jest.Mock };
|
||||
};
|
||||
let webhooks: { dispatch: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
tx = {
|
||||
bottlingBatch: { create: jest.fn() },
|
||||
bottlingBatch: {
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
honeyBatch: { update: jest.fn() },
|
||||
};
|
||||
prisma = {
|
||||
@@ -43,7 +48,7 @@ describe('BottlingService', () => {
|
||||
),
|
||||
};
|
||||
auditLog = { record: jest.fn() };
|
||||
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
|
||||
webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
|
||||
const automation = { generateNumber: jest.fn().mockResolvedValue(null) };
|
||||
service = new BottlingService(
|
||||
prisma as unknown as PrismaService,
|
||||
@@ -131,12 +136,19 @@ describe('BottlingService', () => {
|
||||
});
|
||||
|
||||
it('update writes an audit log entry with before and after data', async () => {
|
||||
const before = { id: 'b1', lotNumber: 'L-1' };
|
||||
const updated = { id: 'b1', lotNumber: 'L-2' };
|
||||
const before = {
|
||||
id: 'b1',
|
||||
lotNumber: 'L-1',
|
||||
honeyBatchId: 'h1',
|
||||
honeyBatch: { id: 'h1', amount: 10 },
|
||||
jarSize: 500,
|
||||
jarCount: 10,
|
||||
totalAmount: 5,
|
||||
};
|
||||
const updated = { ...before, lotNumber: 'L-2' };
|
||||
prisma.bottlingBatch.findUnique
|
||||
.mockResolvedValueOnce(before)
|
||||
.mockResolvedValueOnce(updated);
|
||||
prisma.bottlingBatch.update.mockResolvedValue(updated);
|
||||
|
||||
const result = await service.update('b1', { lotNumber: 'L-2' }, 'user-1');
|
||||
|
||||
@@ -160,4 +172,136 @@ describe('BottlingService', () => {
|
||||
service.update('b1', { productId: 'missing' }, 'user-1'),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('clears the product assignment when productId is null', async () => {
|
||||
const before = {
|
||||
id: 'b1',
|
||||
honeyBatchId: 'h1',
|
||||
honeyBatch: { id: 'h1', amount: 10 },
|
||||
jarSize: 500,
|
||||
jarCount: 10,
|
||||
totalAmount: 5,
|
||||
productId: 'p1',
|
||||
};
|
||||
prisma.bottlingBatch.findUnique
|
||||
.mockResolvedValueOnce(before)
|
||||
.mockResolvedValueOnce({ ...before, productId: null });
|
||||
|
||||
await service.update('b1', { productId: null }, 'user-1');
|
||||
|
||||
expect(prisma.product.findUnique).not.toHaveBeenCalled();
|
||||
const [updateArgs] = tx.bottlingBatch.update.mock.calls as [
|
||||
[{ where: { id: string }; data: { productId: string | null } }],
|
||||
];
|
||||
expect(updateArgs[0].where).toEqual({ id: 'b1' });
|
||||
expect(updateArgs[0].data.productId).toBeNull();
|
||||
});
|
||||
|
||||
it('releases the previous amount before re-reserving a corrected jar count', async () => {
|
||||
const before = {
|
||||
id: 'b1',
|
||||
honeyBatchId: 'h1',
|
||||
honeyBatch: { id: 'h1', amount: 0 },
|
||||
jarSize: 500,
|
||||
jarCount: 20,
|
||||
totalAmount: 10,
|
||||
};
|
||||
prisma.bottlingBatch.findUnique
|
||||
.mockResolvedValueOnce(before)
|
||||
.mockResolvedValueOnce({ ...before, jarCount: 10, totalAmount: 5 });
|
||||
|
||||
await service.update('b1', { jarCount: 10 }, 'user-1');
|
||||
|
||||
const [updateArgs] = tx.bottlingBatch.update.mock.calls as [
|
||||
[{ data: { jarCount: number; totalAmount: number } }],
|
||||
];
|
||||
expect(updateArgs[0].data.jarCount).toBe(10);
|
||||
expect(updateArgs[0].data.totalAmount).toBe(5);
|
||||
expect(tx.honeyBatch.update).toHaveBeenCalledWith({
|
||||
where: { id: 'h1' },
|
||||
data: { amount: 5 },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a corrected amount that exceeds the honey batch capacity', async () => {
|
||||
prisma.bottlingBatch.findUnique.mockResolvedValueOnce({
|
||||
id: 'b1',
|
||||
honeyBatchId: 'h1',
|
||||
honeyBatch: { id: 'h1', amount: 0 },
|
||||
jarSize: 500,
|
||||
jarCount: 10,
|
||||
totalAmount: 5,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.update('b1', { jarCount: 40 }, 'user-1'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('moves the amount between honey batches when the honey batch changes', async () => {
|
||||
const before = {
|
||||
id: 'b1',
|
||||
honeyBatchId: 'h1',
|
||||
honeyBatch: { id: 'h1', amount: 2 },
|
||||
jarSize: 500,
|
||||
jarCount: 10,
|
||||
totalAmount: 5,
|
||||
};
|
||||
prisma.bottlingBatch.findUnique
|
||||
.mockResolvedValueOnce(before)
|
||||
.mockResolvedValueOnce({ ...before, honeyBatchId: 'h2' });
|
||||
prisma.honeyBatch.findUnique.mockResolvedValueOnce({ id: 'h2', amount: 8 });
|
||||
|
||||
await service.update('b1', { honeyBatchId: 'h2' }, 'user-1');
|
||||
|
||||
expect(tx.honeyBatch.update).toHaveBeenCalledWith({
|
||||
where: { id: 'h1' },
|
||||
data: { amount: 7 },
|
||||
});
|
||||
expect(tx.honeyBatch.update).toHaveBeenCalledWith({
|
||||
where: { id: 'h2' },
|
||||
data: { amount: 3, status: 'BOTTLED' },
|
||||
});
|
||||
});
|
||||
|
||||
it('remove gives the amount back and records a DELETE audit entry', async () => {
|
||||
const before = {
|
||||
id: 'b1',
|
||||
honeyBatchId: 'h1',
|
||||
honeyBatch: { id: 'h1', amount: 2 },
|
||||
totalAmount: 5,
|
||||
};
|
||||
prisma.bottlingBatch.findUnique.mockResolvedValueOnce(before);
|
||||
|
||||
const result = await service.remove('b1', 'user-1');
|
||||
|
||||
expect(result).toEqual({ id: 'b1' });
|
||||
expect(tx.bottlingBatch.delete).toHaveBeenCalledWith({
|
||||
where: { id: 'b1' },
|
||||
});
|
||||
expect(tx.honeyBatch.update).toHaveBeenCalledWith({
|
||||
where: { id: 'h1' },
|
||||
data: { amount: 7 },
|
||||
});
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityType: 'BottlingBatch',
|
||||
entityId: 'b1',
|
||||
action: 'DELETE',
|
||||
beforeData: before,
|
||||
}),
|
||||
);
|
||||
expect(webhooks.dispatch).toHaveBeenCalledWith(
|
||||
'batch.updated',
|
||||
before.honeyBatch,
|
||||
);
|
||||
});
|
||||
|
||||
it('remove throws NotFoundException for an unknown bottling batch', async () => {
|
||||
prisma.bottlingBatch.findUnique.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(service.remove('missing', 'user-1')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,15 @@ export interface BottlingBatchFilter {
|
||||
honeyBatchId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Amounts are kept in kilograms with gram precision. Rounding on every write
|
||||
* keeps a honey batch's remaining amount from drifting when a bottling batch
|
||||
* is corrected or removed and its amount is given back.
|
||||
*/
|
||||
function roundKg(amount: number): number {
|
||||
return Math.round(amount * 1000) / 1000;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BottlingService {
|
||||
constructor(
|
||||
@@ -122,8 +131,16 @@ export class BottlingService {
|
||||
return created;
|
||||
}
|
||||
|
||||
/**
|
||||
* Corrects any field of an existing bottling batch. Changing the honey
|
||||
* batch, the jar size or the jar count also rebalances the remaining amount
|
||||
* of the affected honey batch(es): the previously bottled amount is given
|
||||
* back before the corrected amount is taken again, so a correction never
|
||||
* double-charges the source batch.
|
||||
*/
|
||||
async update(id: string, dto: UpdateBottlingBatchDto, actingUserId: string) {
|
||||
const before = await this.findById(id);
|
||||
|
||||
if (dto.productId) {
|
||||
const product = await this.prisma.product.findUnique({
|
||||
where: { id: dto.productId },
|
||||
@@ -132,16 +149,87 @@ export class BottlingService {
|
||||
throw new NotFoundException('Product not found');
|
||||
}
|
||||
}
|
||||
await this.prisma.bottlingBatch.update({
|
||||
|
||||
const honeyBatchId = dto.honeyBatchId ?? before.honeyBatchId;
|
||||
const movedToOtherHoneyBatch = honeyBatchId !== before.honeyBatchId;
|
||||
const jarSize = dto.jarSize ?? before.jarSize;
|
||||
const jarCount = dto.jarCount ?? before.jarCount;
|
||||
const totalAmount = roundKg((jarSize * jarCount) / 1000);
|
||||
|
||||
const targetHoneyBatch = movedToOtherHoneyBatch
|
||||
? await this.prisma.honeyBatch.findUnique({ where: { id: honeyBatchId } })
|
||||
: before.honeyBatch;
|
||||
if (!targetHoneyBatch) {
|
||||
throw new NotFoundException('Honey batch not found');
|
||||
}
|
||||
|
||||
// Capacity on the target batch, with this bottling batch's current amount
|
||||
// released first when it already sits on that same batch.
|
||||
const availableOnTarget =
|
||||
targetHoneyBatch.amount === null
|
||||
? null
|
||||
: roundKg(
|
||||
targetHoneyBatch.amount +
|
||||
(movedToOtherHoneyBatch ? 0 : before.totalAmount),
|
||||
);
|
||||
if (availableOnTarget !== null && totalAmount > availableOnTarget) {
|
||||
throw new BadRequestException(
|
||||
'Abfüllmenge übersteigt die verfügbare Menge der Honigcharge',
|
||||
);
|
||||
}
|
||||
|
||||
// A duplicate lotNumber surfaces as Prisma P2002 and is turned into a
|
||||
// 409 with a field-specific message by AllExceptionsFilter.
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.bottlingBatch.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...dto,
|
||||
honeyBatchId,
|
||||
productId: dto.productId === undefined ? undefined : dto.productId,
|
||||
date: dto.date ? new Date(dto.date) : undefined,
|
||||
jarSize,
|
||||
jarCount,
|
||||
totalAmount,
|
||||
bestBeforeDate: dto.bestBeforeDate
|
||||
? new Date(dto.bestBeforeDate)
|
||||
: undefined,
|
||||
lotNumber: dto.lotNumber,
|
||||
},
|
||||
});
|
||||
|
||||
if (movedToOtherHoneyBatch) {
|
||||
await tx.honeyBatch.update({
|
||||
where: { id: before.honeyBatchId },
|
||||
data: {
|
||||
amount:
|
||||
before.honeyBatch.amount === null
|
||||
? null
|
||||
: roundKg(before.honeyBatch.amount + before.totalAmount),
|
||||
},
|
||||
});
|
||||
await tx.honeyBatch.update({
|
||||
where: { id: honeyBatchId },
|
||||
data: {
|
||||
amount:
|
||||
availableOnTarget === null
|
||||
? null
|
||||
: roundKg(availableOnTarget - totalAmount),
|
||||
status: 'BOTTLED',
|
||||
},
|
||||
});
|
||||
} else if (totalAmount !== before.totalAmount) {
|
||||
await tx.honeyBatch.update({
|
||||
where: { id: honeyBatchId },
|
||||
data: {
|
||||
amount:
|
||||
availableOnTarget === null
|
||||
? null
|
||||
: roundKg(availableOnTarget - totalAmount),
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const bottlingBatch = await this.findById(id);
|
||||
await this.auditLog.record({
|
||||
entityType: 'BottlingBatch',
|
||||
@@ -151,6 +239,42 @@ export class BottlingService {
|
||||
beforeData: before,
|
||||
afterData: bottlingBatch,
|
||||
});
|
||||
await this.webhooks.dispatch('batch.updated', bottlingBatch.honeyBatch);
|
||||
return bottlingBatch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a bottling batch that was entered by mistake and gives its amount
|
||||
* back to the honey batch. The full record is kept in the audit log
|
||||
* (action DELETE, beforeData), so the history survives the row.
|
||||
*
|
||||
* The honey batch's status is deliberately left untouched: the status it
|
||||
* held before being bottled is not recoverable here, and guessing it would
|
||||
* write a fact that never happened.
|
||||
*/
|
||||
async remove(id: string, actingUserId: string) {
|
||||
const before = await this.findById(id);
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.bottlingBatch.delete({ where: { id } });
|
||||
if (before.honeyBatch.amount !== null) {
|
||||
await tx.honeyBatch.update({
|
||||
where: { id: before.honeyBatchId },
|
||||
data: {
|
||||
amount: roundKg(before.honeyBatch.amount + before.totalAmount),
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await this.auditLog.record({
|
||||
entityType: 'BottlingBatch',
|
||||
entityId: id,
|
||||
action: 'DELETE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
});
|
||||
await this.webhooks.dispatch('batch.updated', before.honeyBatch);
|
||||
return { id };
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,63 @@
|
||||
import { PartialType, PickType } from '@nestjs/swagger';
|
||||
import { CreateBottlingBatchDto } from './create-bottling-batch.dto';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsDateString,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsPositive,
|
||||
IsString,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateBottlingBatchDto extends PartialType(
|
||||
PickType(CreateBottlingBatchDto, [
|
||||
'productId',
|
||||
'date',
|
||||
'bestBeforeDate',
|
||||
'lotNumber',
|
||||
] as const),
|
||||
) {}
|
||||
/**
|
||||
* Every field of a bottling batch stays correctable after the fact - a
|
||||
* mistyped jar count or a batch bottled against the wrong honey batch is a
|
||||
* data-entry error, not a fact of production. The service rebalances the
|
||||
* affected honey batch amounts for any change to honeyBatchId/jarSize/
|
||||
* jarCount, so the traceability chain stays consistent.
|
||||
*
|
||||
* `productId: null` explicitly clears the product assignment; omitting the
|
||||
* field leaves it untouched.
|
||||
*/
|
||||
export class UpdateBottlingBatchDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
honeyBatchId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
nullable: true,
|
||||
description: 'null entfernt die Produktzuordnung',
|
||||
})
|
||||
@IsOptional()
|
||||
@ValidateIf((_object, value) => value !== null)
|
||||
@IsString()
|
||||
productId?: string | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
date?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Glasgröße in Gramm' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@IsPositive()
|
||||
jarSize?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@IsPositive()
|
||||
jarCount?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
bestBeforeDate?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
lotNumber?: string;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ describe('ColoniesService', () => {
|
||||
colony: { create: jest.Mock; update: jest.Mock; findUnique: jest.Mock };
|
||||
colonyApiaryHistory: { create: jest.Mock };
|
||||
colonyLineage: { create: jest.Mock };
|
||||
queenColonyHistory: { updateMany: jest.Mock };
|
||||
};
|
||||
let prisma: {
|
||||
colony: {
|
||||
@@ -19,6 +20,7 @@ describe('ColoniesService', () => {
|
||||
create: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
apiary: { findUnique: jest.Mock };
|
||||
colonyApiaryHistory: { findMany: jest.Mock };
|
||||
queenColonyHistory: { findMany: jest.Mock };
|
||||
colonyLineage: { findMany: jest.Mock };
|
||||
@@ -39,6 +41,7 @@ describe('ColoniesService', () => {
|
||||
colony: { create: jest.fn(), update: jest.fn(), findUnique: jest.fn() },
|
||||
colonyApiaryHistory: { create: jest.fn() },
|
||||
colonyLineage: { create: jest.fn() },
|
||||
queenColonyHistory: { updateMany: jest.fn() },
|
||||
};
|
||||
prisma = {
|
||||
colony: {
|
||||
@@ -47,6 +50,11 @@ describe('ColoniesService', () => {
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
apiary: {
|
||||
findUnique: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ id: 'apiary-2', status: 'ACTIVE' }),
|
||||
},
|
||||
colonyApiaryHistory: { findMany: jest.fn() },
|
||||
queenColonyHistory: { findMany: jest.fn() },
|
||||
colonyLineage: { findMany: jest.fn() },
|
||||
@@ -117,7 +125,7 @@ describe('ColoniesService', () => {
|
||||
expect(lineageArg.data.toColonyId).toBe('colony-2');
|
||||
});
|
||||
|
||||
it('merge marks the merged colony as MERGED and records a MERGE lineage entry', async () => {
|
||||
it('merge marks the merged colony as MERGED, releases its queen, and records a MERGE lineage entry', async () => {
|
||||
prisma.colony.findUnique
|
||||
.mockResolvedValueOnce(parentColony)
|
||||
.mockResolvedValueOnce({
|
||||
@@ -127,6 +135,7 @@ describe('ColoniesService', () => {
|
||||
tx.colony.findUnique.mockResolvedValue({
|
||||
id: 'colony-3',
|
||||
status: 'ACTIVE',
|
||||
currentQueenId: 'queen-2',
|
||||
});
|
||||
tx.colony.update.mockResolvedValue({});
|
||||
tx.colonyLineage.create.mockResolvedValue({});
|
||||
@@ -138,10 +147,31 @@ describe('ColoniesService', () => {
|
||||
);
|
||||
|
||||
const [[updateArg]] = tx.colony.update.mock.calls as [
|
||||
[{ where: { id: string }; data: { status: string } }],
|
||||
[
|
||||
{
|
||||
where: { id: string };
|
||||
data: { status: string; currentQueenId: string | null };
|
||||
},
|
||||
],
|
||||
];
|
||||
expect(updateArg.where.id).toBe('colony-3');
|
||||
expect(updateArg.data.status).toBe('MERGED');
|
||||
expect(updateArg.data.currentQueenId).toBeNull();
|
||||
|
||||
const [[historyCloseArg]] = tx.queenColonyHistory.updateMany.mock.calls as [
|
||||
[
|
||||
{
|
||||
where: { queenId: string; colonyId: string; endDate: null };
|
||||
data: { endDate: Date };
|
||||
},
|
||||
],
|
||||
];
|
||||
expect(historyCloseArg.where).toEqual({
|
||||
queenId: 'queen-2',
|
||||
colonyId: 'colony-3',
|
||||
endDate: null,
|
||||
});
|
||||
expect(historyCloseArg.data.endDate).toBeInstanceOf(Date);
|
||||
|
||||
const [[lineageArg]] = tx.colonyLineage.create.mock.calls as [
|
||||
[{ data: { type: string; fromColonyId: string; toColonyId: string } }],
|
||||
@@ -161,9 +191,48 @@ describe('ColoniesService', () => {
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('move rejects a nonexistent destination apiary', async () => {
|
||||
prisma.colony.findUnique.mockResolvedValue(parentColony);
|
||||
prisma.apiary.findUnique.mockResolvedValue(null);
|
||||
await expect(
|
||||
service.move('colony-1', { apiaryId: 'missing' }, 'user-1'),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(tx.colony.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('move rejects an inactive destination apiary', async () => {
|
||||
prisma.colony.findUnique.mockResolvedValue(parentColony);
|
||||
prisma.apiary.findUnique.mockResolvedValue({
|
||||
id: 'apiary-2',
|
||||
status: 'INACTIVE',
|
||||
});
|
||||
await expect(
|
||||
service.move('colony-1', { apiaryId: 'apiary-2' }, 'user-1'),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(tx.colony.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('create rejects an inactive apiary', async () => {
|
||||
prisma.apiary.findUnique.mockResolvedValue({
|
||||
id: 'apiary-1',
|
||||
status: 'INACTIVE',
|
||||
});
|
||||
await expect(
|
||||
service.create(
|
||||
{
|
||||
code: 'V-002',
|
||||
apiaryId: 'apiary-1',
|
||||
colonyType: 'PRODUCTION_COLONY',
|
||||
},
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(prisma.colony.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('dissolve sets status DISSOLVED and records DEACTIVATE', async () => {
|
||||
prisma.colony.findUnique.mockResolvedValue(parentColony);
|
||||
prisma.colony.update.mockResolvedValue({
|
||||
tx.colony.update.mockResolvedValue({
|
||||
...parentColony,
|
||||
status: 'DISSOLVED',
|
||||
});
|
||||
@@ -176,6 +245,67 @@ describe('ColoniesService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("dissolve releases the colony's queen so she can be reassigned later", async () => {
|
||||
prisma.colony.findUnique.mockResolvedValue({
|
||||
...parentColony,
|
||||
currentQueenId: 'queen-1',
|
||||
});
|
||||
tx.colony.update.mockResolvedValue({
|
||||
...parentColony,
|
||||
status: 'DISSOLVED',
|
||||
currentQueenId: null,
|
||||
});
|
||||
|
||||
await service.dissolve('colony-1', 'user-1');
|
||||
|
||||
const [[dissolveUpdateArg]] = tx.colony.update.mock.calls as [
|
||||
[
|
||||
{
|
||||
where: { id: string };
|
||||
data: {
|
||||
status: string;
|
||||
deactivatedAt: Date;
|
||||
currentQueenId: string | null;
|
||||
};
|
||||
},
|
||||
],
|
||||
];
|
||||
expect(dissolveUpdateArg.where).toEqual({ id: 'colony-1' });
|
||||
expect(dissolveUpdateArg.data.status).toBe('DISSOLVED');
|
||||
expect(dissolveUpdateArg.data.currentQueenId).toBeNull();
|
||||
expect(dissolveUpdateArg.data.deactivatedAt).toBeInstanceOf(Date);
|
||||
|
||||
const [[historyCloseArg]] = tx.queenColonyHistory.updateMany.mock.calls as [
|
||||
[
|
||||
{
|
||||
where: { queenId: string; colonyId: string; endDate: null };
|
||||
data: { endDate: Date };
|
||||
},
|
||||
],
|
||||
];
|
||||
expect(historyCloseArg.where).toEqual({
|
||||
queenId: 'queen-1',
|
||||
colonyId: 'colony-1',
|
||||
endDate: null,
|
||||
});
|
||||
expect(historyCloseArg.data.endDate).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('dissolve does not touch queen history for a colony with no queen', async () => {
|
||||
prisma.colony.findUnique.mockResolvedValue({
|
||||
...parentColony,
|
||||
currentQueenId: null,
|
||||
});
|
||||
tx.colony.update.mockResolvedValue({
|
||||
...parentColony,
|
||||
status: 'DISSOLVED',
|
||||
});
|
||||
|
||||
await service.dissolve('colony-1', 'user-1');
|
||||
|
||||
expect(tx.queenColonyHistory.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reactivate throws ConflictException when already active', async () => {
|
||||
prisma.colony.findUnique.mockResolvedValue(parentColony);
|
||||
await expect(
|
||||
@@ -206,4 +336,32 @@ describe('ColoniesService', () => {
|
||||
expect.objectContaining({ action: 'UPDATE' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('writes an apiary-history entry when an edit corrects the apiary', async () => {
|
||||
prisma.colony.findUnique.mockResolvedValue(parentColony);
|
||||
tx.colony.update.mockResolvedValue({
|
||||
...parentColony,
|
||||
apiaryId: 'apiary-2',
|
||||
});
|
||||
|
||||
await service.update('colony-1', { apiaryId: 'apiary-2' }, 'user-1');
|
||||
|
||||
expect(tx.colonyApiaryHistory.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
colonyId: 'colony-1',
|
||||
apiaryId: 'apiary-2',
|
||||
movedByUserId: 'user-1',
|
||||
notes: 'Korrektur über Volk bearbeiten',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('writes no apiary-history entry when an edit leaves the apiary untouched', async () => {
|
||||
prisma.colony.findUnique.mockResolvedValue(parentColony);
|
||||
tx.colony.update.mockResolvedValue(parentColony);
|
||||
|
||||
await service.update('colony-1', { notes: 'geändert' }, 'user-1');
|
||||
|
||||
expect(tx.colonyApiaryHistory.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -46,12 +46,32 @@ export class ColoniesService {
|
||||
private assertActive(colony: Colony): void {
|
||||
if (colony.status !== 'ACTIVE') {
|
||||
throw new ConflictException(
|
||||
`Colony ${colony.id} is not active (status: ${colony.status})`,
|
||||
`Das Volk ist nicht aktiv (Status: ${colony.status})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Colony.apiaryId is a plain FK with no built-in status check - without
|
||||
// this, a colony could be created or moved into an apiary that either
|
||||
// doesn't exist (a raw Prisma FK-constraint error instead of a clean
|
||||
// 404) or is deactivated (silently orphaning the colony the same way an
|
||||
// apiary deactivated out from under an existing colony would).
|
||||
private async assertApiaryActive(apiaryId: string): Promise<void> {
|
||||
const apiary = await this.prisma.apiary.findUnique({
|
||||
where: { id: apiaryId },
|
||||
});
|
||||
if (!apiary) {
|
||||
throw new NotFoundException('Bienenstand nicht gefunden');
|
||||
}
|
||||
if (apiary.status !== 'ACTIVE') {
|
||||
throw new ConflictException(
|
||||
'Der Bienenstand ist nicht aktiv und kann daher keine Völker aufnehmen',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async create(dto: CreateColonyDto, actingUserId: string): Promise<Colony> {
|
||||
await this.assertApiaryActive(dto.apiaryId);
|
||||
const colony = await this.prisma.colony.create({ data: { ...dto } });
|
||||
await this.auditLog.record({
|
||||
entityType: 'Colony',
|
||||
@@ -70,10 +90,32 @@ export class ColoniesService {
|
||||
actingUserId: string,
|
||||
): Promise<Colony> {
|
||||
const before = await this.findById(id);
|
||||
const colony = await this.prisma.colony.update({
|
||||
const movesApiary =
|
||||
dto.apiaryId !== undefined && dto.apiaryId !== before.apiaryId;
|
||||
if (movesApiary) {
|
||||
await this.assertApiaryActive(dto.apiaryId!);
|
||||
}
|
||||
|
||||
const colony = await this.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.colony.update({
|
||||
where: { id },
|
||||
data: { ...dto },
|
||||
});
|
||||
// Correcting the apiary has to leave the same trace a deliberate move
|
||||
// does - otherwise the colony's location history would silently skip a
|
||||
// stand it was recorded on.
|
||||
if (movesApiary) {
|
||||
await tx.colonyApiaryHistory.create({
|
||||
data: {
|
||||
colonyId: id,
|
||||
apiaryId: updated.apiaryId,
|
||||
movedByUserId: actingUserId,
|
||||
notes: 'Korrektur über Volk bearbeiten',
|
||||
},
|
||||
});
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'Colony',
|
||||
entityId: id,
|
||||
@@ -93,6 +135,7 @@ export class ColoniesService {
|
||||
): Promise<Colony> {
|
||||
const before = await this.findById(id);
|
||||
this.assertActive(before);
|
||||
await this.assertApiaryActive(dto.apiaryId);
|
||||
const colony = await this.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.colony.update({
|
||||
where: { id },
|
||||
@@ -169,13 +212,35 @@ export class ColoniesService {
|
||||
where: { id: mergedColonyId },
|
||||
});
|
||||
if (!merged) {
|
||||
throw new NotFoundException(`Colony ${mergedColonyId} not found`);
|
||||
throw new NotFoundException(
|
||||
'Zu vereinigendes Volk wurde nicht gefunden',
|
||||
);
|
||||
}
|
||||
this.assertActive(merged);
|
||||
// currentQueenId is unique across colonies - if it stayed set on
|
||||
// this now-dead colony, any later attempt to assign her queen to a
|
||||
// different colony would fail with a unique-constraint conflict.
|
||||
// The survivor keeps its own queen; merged-away queens are simply
|
||||
// released, not auto-transferred (which colony's queen "wins" a
|
||||
// merge is a beekeeper decision, not one to guess here).
|
||||
await tx.colony.update({
|
||||
where: { id: mergedColonyId },
|
||||
data: { status: 'MERGED', deactivatedAt: new Date() },
|
||||
data: {
|
||||
status: 'MERGED',
|
||||
deactivatedAt: new Date(),
|
||||
currentQueenId: null,
|
||||
},
|
||||
});
|
||||
if (merged.currentQueenId) {
|
||||
await tx.queenColonyHistory.updateMany({
|
||||
where: {
|
||||
queenId: merged.currentQueenId,
|
||||
colonyId: mergedColonyId,
|
||||
endDate: null,
|
||||
},
|
||||
data: { endDate: new Date() },
|
||||
});
|
||||
}
|
||||
await tx.colonyLineage.create({
|
||||
data: {
|
||||
type: 'MERGE',
|
||||
@@ -200,9 +265,26 @@ export class ColoniesService {
|
||||
|
||||
async dissolve(id: string, actingUserId: string): Promise<Colony> {
|
||||
const before = await this.findById(id);
|
||||
const colony = await this.prisma.colony.update({
|
||||
const colony = await this.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.colony.update({
|
||||
where: { id },
|
||||
data: { status: 'DISSOLVED', deactivatedAt: new Date() },
|
||||
data: {
|
||||
status: 'DISSOLVED',
|
||||
deactivatedAt: new Date(),
|
||||
currentQueenId: null,
|
||||
},
|
||||
});
|
||||
if (before.currentQueenId) {
|
||||
await tx.queenColonyHistory.updateMany({
|
||||
where: {
|
||||
queenId: before.currentQueenId,
|
||||
colonyId: id,
|
||||
endDate: null,
|
||||
},
|
||||
data: { endDate: new Date() },
|
||||
});
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'Colony',
|
||||
|
||||
@@ -3,8 +3,16 @@ import { IsEnum, IsOptional } from 'class-validator';
|
||||
import { ColonyStatus } from '../../../generated/prisma';
|
||||
import { CreateColonyDto } from './create-colony.dto';
|
||||
|
||||
/**
|
||||
* `apiaryId` is editable here too, not only through POST /colonies/:id/move:
|
||||
* a colony recorded on the wrong apiary is a data-entry error and must be
|
||||
* correctable. The service writes the same ColonyApiaryHistory entry the move
|
||||
* endpoint writes, so the traceability chain stays intact either way.
|
||||
*/
|
||||
class UpdatableColonyFieldsDto extends PartialType(
|
||||
PickType(CreateColonyDto, [
|
||||
'apiaryId',
|
||||
'code',
|
||||
'hiveType',
|
||||
'originText',
|
||||
'colonyType',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ArgumentsHost, BadRequestException, Logger } from '@nestjs/common';
|
||||
import { Prisma } from '../../../generated/prisma';
|
||||
import { AllExceptionsFilter } from './all-exceptions.filter';
|
||||
|
||||
describe('AllExceptionsFilter', () => {
|
||||
@@ -67,4 +68,112 @@ describe('AllExceptionsFilter', () => {
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
expect(errorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('translates a P2002 unique-constraint error on a known field to a 409 with a specific message', () => {
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
const exception = new Prisma.PrismaClientKnownRequestError(
|
||||
'Unique constraint failed on the fields: (`code`)',
|
||||
{ code: 'P2002', clientVersion: '6.0.0', meta: { target: ['code'] } },
|
||||
);
|
||||
|
||||
filter.catch(exception, host);
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(409);
|
||||
expect(jsonMock).toHaveBeenCalledWith({
|
||||
statusCode: 409,
|
||||
message: 'Code wird bereits verwendet - bitte einen anderen Wert wählen',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to a generic duplicate message for a P2002 on an unmapped or compound field', () => {
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
const exception = new Prisma.PrismaClientKnownRequestError(
|
||||
'Unique constraint failed on the fields: (`sourceType`,`sourceId`)',
|
||||
{
|
||||
code: 'P2002',
|
||||
clientVersion: '6.0.0',
|
||||
meta: { target: ['sourceType', 'sourceId'] },
|
||||
},
|
||||
);
|
||||
|
||||
filter.catch(exception, host);
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(409);
|
||||
expect(jsonMock).toHaveBeenCalledWith({
|
||||
statusCode: 409,
|
||||
message:
|
||||
'Dieser Wert wird bereits verwendet - bitte einen anderen Wert wählen',
|
||||
});
|
||||
});
|
||||
|
||||
it('logs a P2002 error at warn level, not error level, since it is a user input problem', () => {
|
||||
const errorSpy = jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
const warnSpy = jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
const exception = new Prisma.PrismaClientKnownRequestError(
|
||||
'Unique constraint failed on the fields: (`code`)',
|
||||
{ code: 'P2002', clientVersion: '6.0.0', meta: { target: ['code'] } },
|
||||
);
|
||||
|
||||
filter.catch(exception, host);
|
||||
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
expect(errorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('translates a P2003 foreign-key error to a 400 naming the broken relation', () => {
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
const exception = new Prisma.PrismaClientKnownRequestError(
|
||||
'Foreign key constraint failed on the field: `colonyId`',
|
||||
{
|
||||
code: 'P2003',
|
||||
clientVersion: '6.0.0',
|
||||
meta: { field_name: 'colonyId' },
|
||||
},
|
||||
);
|
||||
|
||||
filter.catch(exception, host);
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(400);
|
||||
expect(jsonMock).toHaveBeenCalledWith({
|
||||
statusCode: 400,
|
||||
message:
|
||||
'Der verknüpfte Datensatz existiert nicht (mehr) - bitte die Auswahl prüfen',
|
||||
});
|
||||
});
|
||||
|
||||
it('translates a P2025 missing-record error to a 404', () => {
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
const exception = new Prisma.PrismaClientKnownRequestError(
|
||||
'An operation failed because it depends on one or more records that were required but not found.',
|
||||
{ code: 'P2025', clientVersion: '6.0.0' },
|
||||
);
|
||||
|
||||
filter.catch(exception, host);
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(404);
|
||||
expect(jsonMock).toHaveBeenCalledWith({
|
||||
statusCode: 404,
|
||||
message: 'Datensatz nicht gefunden',
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves an unmapped Prisma error as a generic 500, leaking nothing', () => {
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
const exception = new Prisma.PrismaClientKnownRequestError(
|
||||
'Timed out fetching a new connection from the connection pool',
|
||||
{ code: 'P2024', clientVersion: '6.0.0' },
|
||||
);
|
||||
|
||||
filter.catch(exception, host);
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(500);
|
||||
expect(jsonMock).toHaveBeenCalledWith({
|
||||
statusCode: 500,
|
||||
message: 'Internal server error',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,71 @@ import {
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import { Prisma } from '../../../generated/prisma';
|
||||
|
||||
// Unique-constraint fields a beekeeper might plausibly retype by mistake
|
||||
// (a hive code, a batch number) - translated so the P2002 branch below can
|
||||
// name the actual field instead of a raw column identifier.
|
||||
const UNIQUE_FIELD_LABELS: Record<string, string> = {
|
||||
code: 'Code',
|
||||
email: 'E-Mail-Adresse',
|
||||
sku: 'Artikelnummer (SKU)',
|
||||
batchNumber: 'Chargennummer',
|
||||
lotNumber: 'Los-/Chargennummer',
|
||||
inventoryNumber: 'Inventarnummer',
|
||||
};
|
||||
|
||||
function duplicateFieldMessage(target: unknown): string {
|
||||
const fields = Array.isArray(target)
|
||||
? target.filter((value): value is string => typeof value === 'string')
|
||||
: typeof target === 'string'
|
||||
? [target]
|
||||
: [];
|
||||
const label =
|
||||
fields.length === 1 ? UNIQUE_FIELD_LABELS[fields[0]] : undefined;
|
||||
return label
|
||||
? `${label} wird bereits verwendet - bitte einen anderen Wert wählen`
|
||||
: 'Dieser Wert wird bereits verwendet - bitte einen anderen Wert wählen';
|
||||
}
|
||||
|
||||
/**
|
||||
* A relation pointed at a record that does not exist (Prisma P2003). Every
|
||||
* module lets a record be re-pointed at a different parent - an inspection
|
||||
* filed against the wrong colony, a harvest against the wrong apiary - and
|
||||
* relies on the foreign key rather than an application-level existence check,
|
||||
* so a stale or mistyped id has to come back as a clean 400.
|
||||
*/
|
||||
const FOREIGN_KEY_MESSAGE =
|
||||
'Der verknüpfte Datensatz existiert nicht (mehr) - bitte die Auswahl prüfen';
|
||||
|
||||
/**
|
||||
* Translates the Prisma errors that are really user input problems into the
|
||||
* status and message they deserve. Anything not listed here stays a 500 with
|
||||
* a generic body, so no internal detail leaks.
|
||||
*/
|
||||
function prismaErrorResponse(
|
||||
exception: unknown,
|
||||
): { status: number; message: string } | null {
|
||||
if (!(exception instanceof Prisma.PrismaClientKnownRequestError)) {
|
||||
return null;
|
||||
}
|
||||
switch (exception.code) {
|
||||
case 'P2002':
|
||||
return {
|
||||
status: HttpStatus.CONFLICT,
|
||||
message: duplicateFieldMessage(exception.meta?.target),
|
||||
};
|
||||
case 'P2003':
|
||||
return { status: HttpStatus.BAD_REQUEST, message: FOREIGN_KEY_MESSAGE };
|
||||
case 'P2025':
|
||||
return {
|
||||
status: HttpStatus.NOT_FOUND,
|
||||
message: 'Datensatz nicht gefunden',
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces Nest's default exception handling so EVERY error - not just
|
||||
@@ -20,7 +85,15 @@ import type { Request, Response } from 'express';
|
||||
* Client-facing behaviour is unchanged: HttpExceptions still return
|
||||
* exactly the body/status Nest would have produced. Non-HttpException
|
||||
* errors still collapse to a generic 500 body - the real message and
|
||||
* stack go to the log, never to the client.
|
||||
* stack go to the log, never to the client. The exceptions are the Prisma
|
||||
* errors listed in prismaErrorResponse: services write records straight
|
||||
* against DB constraints (`@unique` columns like colony/apiary/queen codes,
|
||||
* SKUs and batch numbers; foreign keys for every parent relation) with no
|
||||
* application-level check, relying on the constraint itself - so without
|
||||
* those branches, a beekeeper reusing a code or picking a record that was
|
||||
* meanwhile removed saw a bare 500 "Internal server error" instead of a
|
||||
* clean, specific message, and the log filled with fake server errors for
|
||||
* what is really a user input problem.
|
||||
*/
|
||||
@Catch()
|
||||
export class AllExceptionsFilter implements ExceptionFilter {
|
||||
@@ -32,12 +105,16 @@ export class AllExceptionsFilter implements ExceptionFilter {
|
||||
const request = ctx.getRequest<Request>();
|
||||
|
||||
const isHttpException = exception instanceof HttpException;
|
||||
const prismaResponse = prismaErrorResponse(exception);
|
||||
const status = isHttpException
|
||||
? exception.getStatus()
|
||||
: HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
: (prismaResponse?.status ?? HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
const body = isHttpException
|
||||
? exception.getResponse()
|
||||
: { statusCode: status, message: 'Internal server error' };
|
||||
: {
|
||||
statusCode: status,
|
||||
message: prismaResponse?.message ?? 'Internal server error',
|
||||
};
|
||||
const message =
|
||||
exception instanceof Error ? exception.message : 'Unknown error';
|
||||
const context = `${request.method} ${request.originalUrl} -> ${status}`;
|
||||
|
||||
@@ -10,6 +10,7 @@ describe('DashboardService', () => {
|
||||
task: { count: jest.Mock; findMany: jest.Mock };
|
||||
honeyBatch: { count: jest.Mock };
|
||||
stockItem: { findMany: jest.Mock };
|
||||
swarmControlCheck: { findMany: jest.Mock };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -26,6 +27,7 @@ describe('DashboardService', () => {
|
||||
},
|
||||
honeyBatch: { count: jest.fn().mockResolvedValue(1) },
|
||||
stockItem: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
swarmControlCheck: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
};
|
||||
service = new DashboardService(prisma as unknown as PrismaService);
|
||||
});
|
||||
@@ -63,4 +65,77 @@ describe('DashboardService', () => {
|
||||
'LOW_STOCK',
|
||||
]);
|
||||
});
|
||||
|
||||
it('flags a colony whose most recent swarm check is overdue', async () => {
|
||||
prisma.swarmControlCheck.findMany.mockResolvedValue([
|
||||
{
|
||||
colonyId: 'colony-1',
|
||||
nextCheckDate: new Date('2020-01-01'),
|
||||
colony: { code: 'V-001' },
|
||||
},
|
||||
]);
|
||||
|
||||
const alerts = await service.alerts();
|
||||
|
||||
expect(alerts).toEqual([
|
||||
{
|
||||
type: 'OVERDUE_SWARM_CHECK',
|
||||
message: 'Schwarmkontrolle für Volk V-001 überfällig',
|
||||
relatedType: 'Colony',
|
||||
relatedId: 'colony-1',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores an overdue swarm check when a newer check already superseded it', async () => {
|
||||
prisma.swarmControlCheck.findMany.mockResolvedValue([
|
||||
{
|
||||
colonyId: 'colony-1',
|
||||
date: new Date('2026-06-10'),
|
||||
nextCheckDate: null,
|
||||
colony: { code: 'V-001' },
|
||||
},
|
||||
{
|
||||
colonyId: 'colony-1',
|
||||
date: new Date('2026-06-01'),
|
||||
nextCheckDate: new Date('2020-01-01'),
|
||||
colony: { code: 'V-001' },
|
||||
},
|
||||
]);
|
||||
|
||||
const alerts = await service.alerts();
|
||||
|
||||
expect(alerts).toHaveLength(0);
|
||||
});
|
||||
|
||||
describe('OLD_QUEEN', () => {
|
||||
it('flags a colony whose queen is at least 3 years old', async () => {
|
||||
prisma.colony.findMany
|
||||
.mockResolvedValueOnce([]) // attentionColonies
|
||||
.mockResolvedValueOnce([
|
||||
{ id: 'colony-1', code: 'V-001', currentQueen: { hatchYear: 2020 } },
|
||||
]);
|
||||
|
||||
const alerts = await service.alerts();
|
||||
|
||||
expect(alerts).toHaveLength(1);
|
||||
expect(alerts[0].type).toBe('OLD_QUEEN');
|
||||
expect(alerts[0].relatedId).toBe('colony-1');
|
||||
});
|
||||
|
||||
it('does not flag a queen younger than the threshold', async () => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
prisma.colony.findMany.mockResolvedValueOnce([]).mockResolvedValueOnce([
|
||||
{
|
||||
id: 'colony-1',
|
||||
code: 'V-001',
|
||||
currentQueen: { hatchYear: currentYear },
|
||||
},
|
||||
]);
|
||||
|
||||
const alerts = await service.alerts();
|
||||
|
||||
expect(alerts).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,13 +11,22 @@ export interface DashboardSummary {
|
||||
}
|
||||
|
||||
export interface DashboardAlert {
|
||||
type: 'OVERDUE_TASK' | 'COLONY_ATTENTION' | 'LOW_STOCK';
|
||||
type:
|
||||
| 'OVERDUE_TASK'
|
||||
| 'COLONY_ATTENTION'
|
||||
| 'LOW_STOCK'
|
||||
| 'OVERDUE_SWARM_CHECK'
|
||||
| 'OLD_QUEEN';
|
||||
message: string;
|
||||
relatedType: string;
|
||||
relatedId: string;
|
||||
}
|
||||
|
||||
const ATTENTION_COLONY_STATUSES = ['WEAK', 'QUEENLESS', 'CONSPICUOUS'] as const;
|
||||
// Queens rarely stay reliably productive past their third season - laying
|
||||
// rate and swarming risk both worsen well before then, so this flags a
|
||||
// colony for evaluation rather than demanding immediate requeening.
|
||||
const OLD_QUEEN_AGE_YEARS = 3;
|
||||
|
||||
@Injectable()
|
||||
export class DashboardService {
|
||||
@@ -60,7 +69,13 @@ export class DashboardService {
|
||||
}
|
||||
|
||||
async alerts(): Promise<DashboardAlert[]> {
|
||||
const [overdueTasks, attentionColonies, stockItems] = await Promise.all([
|
||||
const [
|
||||
overdueTasks,
|
||||
attentionColonies,
|
||||
stockItems,
|
||||
swarmChecks,
|
||||
queenedColonies,
|
||||
] = await Promise.all([
|
||||
this.prisma.task.findMany({
|
||||
where: {
|
||||
status: { in: ['OPEN', 'IN_PROGRESS'] },
|
||||
@@ -73,6 +88,26 @@ export class DashboardService {
|
||||
this.prisma.stockItem.findMany({
|
||||
where: { minThreshold: { not: null } },
|
||||
}),
|
||||
this.prisma.swarmControlCheck.findMany({
|
||||
where: { colony: { status: 'ACTIVE' } },
|
||||
orderBy: [{ colonyId: 'asc' }, { date: 'desc' }],
|
||||
select: {
|
||||
colonyId: true,
|
||||
nextCheckDate: true,
|
||||
colony: { select: { code: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.colony.findMany({
|
||||
where: {
|
||||
status: 'ACTIVE',
|
||||
currentQueen: { hatchYear: { not: null } },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
currentQueen: { select: { hatchYear: true } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const alerts: DashboardAlert[] = [];
|
||||
@@ -102,6 +137,43 @@ export class DashboardService {
|
||||
});
|
||||
}
|
||||
}
|
||||
// swarmChecks is ordered by colonyId then date desc, so the first entry
|
||||
// seen per colonyId is that colony's most recent check - only the most
|
||||
// recent nextCheckDate matters, an older overdue one has been superseded.
|
||||
const now = new Date();
|
||||
const latestCheckByColony = new Map<string, (typeof swarmChecks)[number]>();
|
||||
for (const check of swarmChecks) {
|
||||
if (!latestCheckByColony.has(check.colonyId)) {
|
||||
latestCheckByColony.set(check.colonyId, check);
|
||||
}
|
||||
}
|
||||
for (const check of latestCheckByColony.values()) {
|
||||
if (check.nextCheckDate && check.nextCheckDate < now) {
|
||||
alerts.push({
|
||||
type: 'OVERDUE_SWARM_CHECK',
|
||||
message: `Schwarmkontrolle für Volk ${check.colony.code} überfällig`,
|
||||
relatedType: 'Colony',
|
||||
relatedId: check.colonyId,
|
||||
});
|
||||
}
|
||||
}
|
||||
const currentYear = now.getFullYear();
|
||||
for (const colony of queenedColonies) {
|
||||
// The where clause above already requires currentQueen and its
|
||||
// hatchYear to exist, but the relation is still nullable in the type.
|
||||
if (!colony.currentQueen?.hatchYear) {
|
||||
continue;
|
||||
}
|
||||
const ageYears = currentYear - colony.currentQueen.hatchYear;
|
||||
if (ageYears >= OLD_QUEEN_AGE_YEARS) {
|
||||
alerts.push({
|
||||
type: 'OLD_QUEEN',
|
||||
message: `Königin in Volk ${colony.code} ist ${ageYears} Jahre alt - Nachzucht prüfen`,
|
||||
relatedType: 'Colony',
|
||||
relatedId: colony.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
return alerts;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { buildColonyAnnualReport } from './annual-reports';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
describe('buildColonyAnnualReport', () => {
|
||||
let prisma: {
|
||||
colony: { findUnique: jest.Mock };
|
||||
inspection: { findMany: jest.Mock };
|
||||
treatment: { findMany: jest.Mock };
|
||||
varroaMeasurement: { findMany: jest.Mock };
|
||||
swarmControlCheck: { findMany: jest.Mock };
|
||||
harvestColony: { findMany: jest.Mock };
|
||||
};
|
||||
|
||||
const colony = {
|
||||
id: 'colony-1',
|
||||
code: 'V-001',
|
||||
colonyType: 'PRODUCTION_COLONY',
|
||||
status: 'ACTIVE',
|
||||
apiary: { id: 'apiary-1', name: 'Heimstand' },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
prisma = {
|
||||
colony: { findUnique: jest.fn().mockResolvedValue(colony) },
|
||||
inspection: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
treatment: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
varroaMeasurement: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
swarmControlCheck: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
harvestColony: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
};
|
||||
});
|
||||
|
||||
it('throws NotFoundException for an unknown colony', async () => {
|
||||
prisma.colony.findUnique.mockResolvedValue(null);
|
||||
await expect(
|
||||
buildColonyAnnualReport(
|
||||
prisma as unknown as PrismaService,
|
||||
'missing',
|
||||
2026,
|
||||
),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('carries activeIngredient, batchNumber and withdrawalPeriodDays through for each treatment', async () => {
|
||||
prisma.treatment.findMany.mockResolvedValue([
|
||||
{
|
||||
date: new Date('2026-05-01'),
|
||||
product: 'Ameisensäure 60%',
|
||||
amount: '15 ml',
|
||||
activeIngredient: 'Ameisensäure',
|
||||
batchNumber: 'CH-2026-01',
|
||||
withdrawalPeriodDays: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
const report = await buildColonyAnnualReport(
|
||||
prisma as unknown as PrismaService,
|
||||
'colony-1',
|
||||
2026,
|
||||
);
|
||||
|
||||
expect(report.treatments).toEqual([
|
||||
{
|
||||
date: new Date('2026-05-01'),
|
||||
product: 'Ameisensäure 60%',
|
||||
amount: '15 ml',
|
||||
activeIngredient: 'Ameisensäure',
|
||||
batchNumber: 'CH-2026-01',
|
||||
withdrawalPeriodDays: 0,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -37,7 +37,14 @@ export interface ColonyAnnualReport {
|
||||
queenSeen: boolean | null;
|
||||
broodAmount: string | null;
|
||||
}[];
|
||||
treatments: { date: Date; product: string; amount: string | null }[];
|
||||
treatments: {
|
||||
date: Date;
|
||||
product: string;
|
||||
amount: string | null;
|
||||
activeIngredient: string | null;
|
||||
batchNumber: string | null;
|
||||
withdrawalPeriodDays: number | null;
|
||||
}[];
|
||||
varroaMeasurements: {
|
||||
date: Date;
|
||||
method: string;
|
||||
@@ -141,6 +148,9 @@ export async function buildColonyAnnualReport(
|
||||
date: entry.date,
|
||||
product: entry.product,
|
||||
amount: entry.amount,
|
||||
activeIngredient: entry.activeIngredient,
|
||||
batchNumber: entry.batchNumber,
|
||||
withdrawalPeriodDays: entry.withdrawalPeriodDays,
|
||||
})),
|
||||
varroaMeasurements: varroaMeasurements.map((entry) => ({
|
||||
date: entry.date,
|
||||
@@ -262,7 +272,14 @@ export function renderColonyAnnualReportPdf(
|
||||
}
|
||||
for (const entry of report.treatments) {
|
||||
doc.text(
|
||||
`- ${formatDate(entry.date)}: ${entry.product} (${entry.amount ?? '-'})`,
|
||||
`- ${formatDate(entry.date)}: ${entry.product} (${entry.amount ?? '-'})` +
|
||||
(entry.activeIngredient
|
||||
? `, Wirkstoff: ${entry.activeIngredient}`
|
||||
: '') +
|
||||
(entry.batchNumber ? `, Charge: ${entry.batchNumber}` : '') +
|
||||
(entry.withdrawalPeriodDays !== null
|
||||
? `, Wartezeit: ${entry.withdrawalPeriodDays} Tage`
|
||||
: ''),
|
||||
);
|
||||
}
|
||||
doc.moveDown();
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateExtractionDto {
|
||||
@@ -22,6 +24,7 @@ export class CreateExtractionDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
amount?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@@ -32,6 +35,8 @@ export class CreateExtractionDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
waterContent?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { PartialType, PickType } from '@nestjs/swagger';
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateExtractionDto } from './create-extraction.dto';
|
||||
|
||||
export class UpdateExtractionDto extends PartialType(
|
||||
PickType(CreateExtractionDto, [
|
||||
'date',
|
||||
'amount',
|
||||
'room',
|
||||
'waterContent',
|
||||
'temperature',
|
||||
'notes',
|
||||
] as const),
|
||||
) {}
|
||||
/**
|
||||
* Every field stays correctable, the set of harvests included: a Schleuderung
|
||||
* linked to the wrong Ernte is a data-entry error, and re-pointing it is the
|
||||
* only way to repair the traceability chain without deleting the record.
|
||||
*/
|
||||
export class UpdateExtractionDto extends PartialType(CreateExtractionDto) {}
|
||||
@@ -12,16 +12,19 @@ describe('ExtractionsService', () => {
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
let tx: {
|
||||
extraction: { create: jest.Mock };
|
||||
extractionHarvest: { createMany: jest.Mock };
|
||||
extraction: { create: jest.Mock; update: jest.Mock };
|
||||
extractionHarvest: { createMany: jest.Mock; deleteMany: jest.Mock };
|
||||
};
|
||||
|
||||
const extraction = { id: 'extraction-1', date: new Date() };
|
||||
|
||||
beforeEach(() => {
|
||||
tx = {
|
||||
extraction: { create: jest.fn().mockResolvedValue(extraction) },
|
||||
extractionHarvest: { createMany: jest.fn() },
|
||||
extraction: {
|
||||
create: jest.fn().mockResolvedValue(extraction),
|
||||
update: jest.fn(),
|
||||
},
|
||||
extractionHarvest: { createMany: jest.fn(), deleteMany: jest.fn() },
|
||||
};
|
||||
prisma = {
|
||||
extraction: { findUnique: jest.fn(), update: jest.fn() },
|
||||
@@ -82,7 +85,6 @@ describe('ExtractionsService', () => {
|
||||
prisma.extraction.findUnique
|
||||
.mockResolvedValueOnce(extraction)
|
||||
.mockResolvedValueOnce(updated);
|
||||
prisma.extraction.update.mockResolvedValue(updated);
|
||||
|
||||
const result = await service.update(
|
||||
'extraction-1',
|
||||
@@ -100,5 +102,41 @@ describe('ExtractionsService', () => {
|
||||
afterData: updated,
|
||||
}),
|
||||
);
|
||||
expect(tx.extractionHarvest.deleteMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('update replaces the whole harvest set when harvestIds is given', async () => {
|
||||
prisma.extraction.findUnique.mockResolvedValue(extraction);
|
||||
|
||||
await service.update(
|
||||
'extraction-1',
|
||||
{ harvestIds: ['harvest-1', 'harvest-2'] },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(tx.extractionHarvest.deleteMany).toHaveBeenCalledWith({
|
||||
where: { extractionId: 'extraction-1' },
|
||||
});
|
||||
const [[createArg]] = tx.extractionHarvest.createMany.mock.calls as [
|
||||
[{ data: { extractionId: string; harvestId: string }[] }],
|
||||
];
|
||||
expect(createArg.data).toEqual([
|
||||
{ extractionId: 'extraction-1', harvestId: 'harvest-1' },
|
||||
{ extractionId: 'extraction-1', harvestId: 'harvest-2' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('update rejects an unknown harvest id before touching the database', async () => {
|
||||
prisma.extraction.findUnique.mockResolvedValue(extraction);
|
||||
prisma.harvest.findMany.mockResolvedValue([{ id: 'harvest-1' }]);
|
||||
|
||||
await expect(
|
||||
service.update(
|
||||
'extraction-1',
|
||||
{ harvestIds: ['harvest-1', 'missing-harvest'] },
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -89,10 +89,32 @@ export class ExtractionsService {
|
||||
|
||||
async update(id: string, dto: UpdateExtractionDto, actingUserId: string) {
|
||||
const before = await this.findById(id);
|
||||
await this.prisma.extraction.update({
|
||||
const { harvestIds, ...scalarFields } = dto;
|
||||
if (harvestIds) {
|
||||
await this.assertHarvestsExist(harvestIds);
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.extraction.update({
|
||||
where: { id },
|
||||
data: { ...dto, date: dto.date ? new Date(dto.date) : undefined },
|
||||
data: {
|
||||
...scalarFields,
|
||||
date: dto.date ? new Date(dto.date) : undefined,
|
||||
},
|
||||
});
|
||||
// The join rows carry no data of their own, so replacing the whole set
|
||||
// is both simpler and safer than diffing it.
|
||||
if (harvestIds) {
|
||||
await tx.extractionHarvest.deleteMany({ where: { extractionId: id } });
|
||||
await tx.extractionHarvest.createMany({
|
||||
data: harvestIds.map((harvestId) => ({
|
||||
extractionId: id,
|
||||
harvestId,
|
||||
})),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const extraction = await this.findById(id);
|
||||
await this.auditLog.record({
|
||||
entityType: 'Extraction',
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { FeedType } from '../../../generated/prisma';
|
||||
|
||||
@@ -28,6 +29,7 @@ export class CreateFeedingDto {
|
||||
|
||||
@ApiProperty()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
amount!: number;
|
||||
|
||||
@ApiProperty()
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import { PartialType, PickType } from '@nestjs/swagger';
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateFeedingDto } from './create-feeding.dto';
|
||||
|
||||
export class UpdateFeedingDto extends PartialType(
|
||||
PickType(CreateFeedingDto, [
|
||||
'date',
|
||||
'feedType',
|
||||
'product',
|
||||
'amount',
|
||||
'unit',
|
||||
'batchNumber',
|
||||
'notes',
|
||||
] as const),
|
||||
) {}
|
||||
/**
|
||||
* Every field stays correctable, `colonyId` included: a record filed against
|
||||
* the wrong colony is a data-entry error, and re-filing it is the only way to
|
||||
* repair the colony's history without deleting the record.
|
||||
*/
|
||||
export class UpdateFeedingDto extends PartialType(CreateFeedingDto) {}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateHarvestDto {
|
||||
@@ -32,16 +33,19 @@ export class CreateHarvestDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
honeySuperCount?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
grossWeight?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
netWeight?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { PartialType, PickType } from '@nestjs/swagger';
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateHarvestDto } from './create-harvest.dto';
|
||||
|
||||
export class UpdateHarvestDto extends PartialType(
|
||||
PickType(CreateHarvestDto, [
|
||||
'date',
|
||||
'honeyType',
|
||||
'honeySuperCount',
|
||||
'grossWeight',
|
||||
'netWeight',
|
||||
'notes',
|
||||
] as const),
|
||||
) {}
|
||||
/**
|
||||
* Every field stays correctable, the apiary and the set of colonies included:
|
||||
* an Ernte booked against the wrong stand or with a colony forgotten is a
|
||||
* data-entry error, and re-pointing it is the only way to repair the
|
||||
* traceability chain without deleting the record.
|
||||
*/
|
||||
export class UpdateHarvestDto extends PartialType(CreateHarvestDto) {}
|
||||
@@ -14,16 +14,19 @@ describe('HarvestsService', () => {
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
let tx: {
|
||||
harvest: { create: jest.Mock };
|
||||
harvestColony: { createMany: jest.Mock };
|
||||
harvest: { create: jest.Mock; update: jest.Mock };
|
||||
harvestColony: { createMany: jest.Mock; deleteMany: jest.Mock };
|
||||
};
|
||||
|
||||
const harvest = { id: 'harvest-1', apiaryId: 'apiary-1', date: new Date() };
|
||||
|
||||
beforeEach(() => {
|
||||
tx = {
|
||||
harvest: { create: jest.fn().mockResolvedValue(harvest) },
|
||||
harvestColony: { createMany: jest.fn() },
|
||||
harvest: {
|
||||
create: jest.fn().mockResolvedValue(harvest),
|
||||
update: jest.fn(),
|
||||
},
|
||||
harvestColony: { createMany: jest.fn(), deleteMany: jest.fn() },
|
||||
};
|
||||
prisma = {
|
||||
harvest: { findUnique: jest.fn(), update: jest.fn() },
|
||||
@@ -96,7 +99,6 @@ describe('HarvestsService', () => {
|
||||
prisma.harvest.findUnique
|
||||
.mockResolvedValueOnce(harvest)
|
||||
.mockResolvedValueOnce(updated);
|
||||
prisma.harvest.update.mockResolvedValue(updated);
|
||||
|
||||
const result = await service.update(
|
||||
'harvest-1',
|
||||
@@ -105,6 +107,7 @@ describe('HarvestsService', () => {
|
||||
);
|
||||
|
||||
expect(result).toEqual(updated);
|
||||
expect(tx.harvestColony.deleteMany).not.toHaveBeenCalled();
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityType: 'Harvest',
|
||||
@@ -115,4 +118,39 @@ describe('HarvestsService', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('update replaces the whole colony set when colonyIds is given', async () => {
|
||||
prisma.harvest.findUnique.mockResolvedValue(harvest);
|
||||
|
||||
await service.update(
|
||||
'harvest-1',
|
||||
{ colonyIds: ['colony-1', 'colony-2'] },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(tx.harvestColony.deleteMany).toHaveBeenCalledWith({
|
||||
where: { harvestId: 'harvest-1' },
|
||||
});
|
||||
const [[createArg]] = tx.harvestColony.createMany.mock.calls as [
|
||||
[{ data: { harvestId: string; colonyId: string }[] }],
|
||||
];
|
||||
expect(createArg.data).toEqual([
|
||||
{ harvestId: 'harvest-1', colonyId: 'colony-1' },
|
||||
{ harvestId: 'harvest-1', colonyId: 'colony-2' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('update rejects an unknown colony id before touching the database', async () => {
|
||||
prisma.harvest.findUnique.mockResolvedValue(harvest);
|
||||
prisma.colony.findMany.mockResolvedValue([{ id: 'colony-1' }]);
|
||||
|
||||
await expect(
|
||||
service.update(
|
||||
'harvest-1',
|
||||
{ colonyIds: ['colony-1', 'missing-colony'] },
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -93,10 +93,29 @@ export class HarvestsService {
|
||||
|
||||
async update(id: string, dto: UpdateHarvestDto, actingUserId: string) {
|
||||
const before = await this.findById(id);
|
||||
await this.prisma.harvest.update({
|
||||
const { colonyIds, ...scalarFields } = dto;
|
||||
if (colonyIds) {
|
||||
await this.assertColoniesExist(colonyIds);
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.harvest.update({
|
||||
where: { id },
|
||||
data: { ...dto, date: dto.date ? new Date(dto.date) : undefined },
|
||||
data: {
|
||||
...scalarFields,
|
||||
date: dto.date ? new Date(dto.date) : undefined,
|
||||
},
|
||||
});
|
||||
// The join rows carry no data of their own, so replacing the whole set
|
||||
// is both simpler and safer than diffing it.
|
||||
if (colonyIds) {
|
||||
await tx.harvestColony.deleteMany({ where: { harvestId: id } });
|
||||
await tx.harvestColony.createMany({
|
||||
data: colonyIds.map((colonyId) => ({ harvestId: id, colonyId })),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const harvest = await this.findById(id);
|
||||
await this.auditLog.record({
|
||||
entityType: 'Harvest',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsNumber, IsOptional, IsString } from 'class-validator';
|
||||
import { IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
|
||||
export class CreateHoneyBatchDto {
|
||||
@ApiPropertyOptional({
|
||||
@@ -22,11 +22,14 @@ export class CreateHoneyBatchDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
amount?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
waterContent?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsDateString, IsNumber, IsOptional, IsString } from 'class-validator';
|
||||
import {
|
||||
IsDateString,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateHoneyMeasurementDto {
|
||||
@ApiProperty()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
waterContent!: number;
|
||||
|
||||
@ApiProperty()
|
||||
|
||||
@@ -1,10 +1,33 @@
|
||||
import { PartialType, PickType } from '@nestjs/swagger';
|
||||
import { ApiPropertyOptional, PartialType, PickType } from '@nestjs/swagger';
|
||||
import { IsEnum, IsOptional } from 'class-validator';
|
||||
import { HoneyBatchStatus } from '../../../generated/prisma';
|
||||
import { CreateHoneyBatchDto } from './create-honey-batch.dto';
|
||||
|
||||
export class UpdateHoneyBatchDto extends PartialType(
|
||||
/**
|
||||
* `storageContainerId` is editable here too, not only through
|
||||
* POST /honey-batches/:id/relocate: a batch recorded in the wrong container is
|
||||
* a data-entry error and must be correctable. The service writes the same
|
||||
* HoneyBatchRelocation entry the relocate endpoint writes.
|
||||
*/
|
||||
class UpdatableHoneyBatchFieldsDto extends PartialType(
|
||||
PickType(CreateHoneyBatchDto, [
|
||||
'batchNumber',
|
||||
'extractionId',
|
||||
'honeyType',
|
||||
'amount',
|
||||
'waterContent',
|
||||
'storageContainerId',
|
||||
] as const),
|
||||
) {}
|
||||
|
||||
export class UpdateHoneyBatchDto extends UpdatableHoneyBatchFieldsDto {
|
||||
/**
|
||||
* Set automatically to BOTTLED when the batch is bottled, but a beekeeper
|
||||
* still has to be able to record that a batch was sold or consumed, or to
|
||||
* undo a status that was set by mistake.
|
||||
*/
|
||||
@ApiPropertyOptional({ enum: HoneyBatchStatus })
|
||||
@IsOptional()
|
||||
@IsEnum(HoneyBatchStatus)
|
||||
status?: HoneyBatchStatus;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateHoneyMeasurementDto } from './create-honey-measurement.dto';
|
||||
|
||||
/**
|
||||
* A water-content reading typed in wrong has to be correctable: it decides
|
||||
* whether a batch may be sold as honey at all, so a wrong figure left standing
|
||||
* is worse than one that was never recorded.
|
||||
*/
|
||||
export class UpdateHoneyMeasurementDto extends PartialType(
|
||||
CreateHoneyMeasurementDto,
|
||||
) {}
|
||||
@@ -17,6 +17,7 @@ import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||
import { CreateHoneyBatchDto } from './dto/create-honey-batch.dto';
|
||||
import { UpdateHoneyBatchDto } from './dto/update-honey-batch.dto';
|
||||
import { CreateHoneyMeasurementDto } from './dto/create-honey-measurement.dto';
|
||||
import { UpdateHoneyMeasurementDto } from './dto/update-honey-measurement.dto';
|
||||
import { RelocateHoneyBatchDto } from './dto/relocate-honey-batch.dto';
|
||||
import { SetHoneyBatchContainerAllocationDto } from './dto/set-honey-batch-container-allocation.dto';
|
||||
import { HoneyBatchesService } from './honey-batches.service';
|
||||
@@ -117,6 +118,20 @@ export class HoneyBatchesController {
|
||||
}
|
||||
|
||||
@RequirePermission('honey-batches:measure')
|
||||
@RequirePermission('honey-batches:measure')
|
||||
@Patch('measurements/:measurementId')
|
||||
updateMeasurement(
|
||||
@Param('measurementId') measurementId: string,
|
||||
@Body() dto: UpdateHoneyMeasurementDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.honeyBatchesService.updateMeasurement(
|
||||
measurementId,
|
||||
dto,
|
||||
actor.id,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/measurements')
|
||||
addMeasurement(
|
||||
@Param('id') id: string,
|
||||
|
||||
@@ -14,7 +14,11 @@ describe('HoneyBatchesService', () => {
|
||||
create: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
honeyMeasurement: { create: jest.Mock };
|
||||
honeyMeasurement: {
|
||||
create: jest.Mock;
|
||||
findUnique: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
storageContainer: { findUnique: jest.Mock };
|
||||
honeyBatchRelocation: { create: jest.Mock; findMany: jest.Mock };
|
||||
honeyBatchContainerAllocation: {
|
||||
@@ -60,7 +64,11 @@ describe('HoneyBatchesService', () => {
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
honeyMeasurement: { create: jest.fn() },
|
||||
honeyMeasurement: {
|
||||
create: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
storageContainer: { findUnique: jest.fn() },
|
||||
honeyBatchRelocation: { create: jest.fn(), findMany: jest.fn() },
|
||||
honeyBatchContainerAllocation: {
|
||||
@@ -354,4 +362,85 @@ describe('HoneyBatchesService', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
beforeEach(() => {
|
||||
prisma.$transaction.mockImplementation(
|
||||
async (
|
||||
callback: (tx: typeof prisma) => Promise<unknown>,
|
||||
): Promise<unknown> => callback(prisma),
|
||||
);
|
||||
});
|
||||
|
||||
it('records a relocation when an edit corrects the storage container', async () => {
|
||||
prisma.honeyBatch.findUnique.mockResolvedValue({
|
||||
...honeyBatch,
|
||||
storageContainerId: 'container-1',
|
||||
});
|
||||
|
||||
await service.update(
|
||||
'batch-1',
|
||||
{ storageContainerId: 'container-2' },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(prisma.honeyBatchRelocation.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
honeyBatchId: 'batch-1',
|
||||
fromStorageContainerId: 'container-1',
|
||||
toStorageContainerId: 'container-2',
|
||||
userId: 'user-1',
|
||||
notes: 'Korrektur über Honigcharge bearbeiten',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('records no relocation when an edit leaves the container untouched', async () => {
|
||||
prisma.honeyBatch.findUnique.mockResolvedValue({
|
||||
...honeyBatch,
|
||||
storageContainerId: 'container-1',
|
||||
});
|
||||
|
||||
await service.update('batch-1', { honeyType: 'Waldhonig' }, 'user-1');
|
||||
|
||||
expect(prisma.honeyBatchRelocation.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('updateMeasurement corrects a reading and logs before/after', async () => {
|
||||
const before = {
|
||||
id: 'measurement-1',
|
||||
honeyBatchId: 'batch-1',
|
||||
waterContent: 22,
|
||||
};
|
||||
prisma.honeyMeasurement.findUnique.mockResolvedValue(before);
|
||||
prisma.honeyMeasurement.update.mockResolvedValue({
|
||||
...before,
|
||||
waterContent: 17.5,
|
||||
});
|
||||
prisma.honeyBatch.findUnique.mockResolvedValue(honeyBatch);
|
||||
|
||||
await service.updateMeasurement(
|
||||
'measurement-1',
|
||||
{ waterContent: 17.5 },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityType: 'HoneyMeasurement',
|
||||
entityId: 'measurement-1',
|
||||
action: 'UPDATE',
|
||||
beforeData: before,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('updateMeasurement throws NotFoundException for an unknown reading', async () => {
|
||||
prisma.honeyMeasurement.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.updateMeasurement('missing', { waterContent: 17.5 }, 'user-1'),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import { HoneyBatchStatus } from '../../generated/prisma';
|
||||
import { CreateHoneyBatchDto } from './dto/create-honey-batch.dto';
|
||||
import { UpdateHoneyBatchDto } from './dto/update-honey-batch.dto';
|
||||
import { CreateHoneyMeasurementDto } from './dto/create-honey-measurement.dto';
|
||||
import { UpdateHoneyMeasurementDto } from './dto/update-honey-measurement.dto';
|
||||
import { RelocateHoneyBatchDto } from './dto/relocate-honey-batch.dto';
|
||||
import { SetHoneyBatchContainerAllocationDto } from './dto/set-honey-batch-container-allocation.dto';
|
||||
|
||||
@@ -86,10 +87,30 @@ export class HoneyBatchesService {
|
||||
|
||||
async update(id: string, dto: UpdateHoneyBatchDto, actingUserId: string) {
|
||||
const before = await this.findById(id);
|
||||
await this.prisma.honeyBatch.update({
|
||||
const movesContainer =
|
||||
dto.storageContainerId !== undefined &&
|
||||
dto.storageContainerId !== before.storageContainerId;
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.honeyBatch.update({
|
||||
where: { id },
|
||||
data: { ...dto },
|
||||
});
|
||||
// Correcting the container has to leave the same trace a deliberate
|
||||
// relocation does - otherwise the batch's storage history would skip a
|
||||
// container it was recorded in.
|
||||
if (movesContainer) {
|
||||
await tx.honeyBatchRelocation.create({
|
||||
data: {
|
||||
honeyBatchId: id,
|
||||
fromStorageContainerId: before.storageContainerId,
|
||||
toStorageContainerId: dto.storageContainerId!,
|
||||
userId: actingUserId,
|
||||
notes: 'Korrektur über Honigcharge bearbeiten',
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
const updated = await this.findById(id);
|
||||
await this.auditLog.record({
|
||||
entityType: 'HoneyBatch',
|
||||
@@ -102,6 +123,35 @@ export class HoneyBatchesService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
async updateMeasurement(
|
||||
measurementId: string,
|
||||
dto: UpdateHoneyMeasurementDto,
|
||||
actingUserId: string,
|
||||
) {
|
||||
const before = await this.prisma.honeyMeasurement.findUnique({
|
||||
where: { id: measurementId },
|
||||
});
|
||||
if (!before) {
|
||||
throw new NotFoundException('Honey measurement not found');
|
||||
}
|
||||
const measurement = await this.prisma.honeyMeasurement.update({
|
||||
where: { id: measurementId },
|
||||
data: {
|
||||
...dto,
|
||||
measuredAt: dto.measuredAt ? new Date(dto.measuredAt) : undefined,
|
||||
},
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'HoneyMeasurement',
|
||||
entityId: measurementId,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: measurement,
|
||||
});
|
||||
return this.findById(before.honeyBatchId);
|
||||
}
|
||||
|
||||
async addMeasurement(
|
||||
id: string,
|
||||
dto: CreateHoneyMeasurementDto,
|
||||
|
||||
@@ -1,27 +1,9 @@
|
||||
import { PartialType, PickType } from '@nestjs/swagger';
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateInspectionDto } from './create-inspection.dto';
|
||||
|
||||
export class UpdateInspectionDto extends PartialType(
|
||||
PickType(CreateInspectionDto, [
|
||||
'date',
|
||||
'weather',
|
||||
'temperature',
|
||||
'colonyStrength',
|
||||
'broodAmount',
|
||||
'broodPattern',
|
||||
'openBroodPresent',
|
||||
'cappedBroodPresent',
|
||||
'eggsPresent',
|
||||
'queenSeen',
|
||||
'queenCellsPresent',
|
||||
'swarmMood',
|
||||
'gentleness',
|
||||
'combBehavior',
|
||||
'feedStatus',
|
||||
'honeySuperStatus',
|
||||
'healthIssues',
|
||||
'varroaIssues',
|
||||
'measures',
|
||||
'notes',
|
||||
] as const),
|
||||
) {}
|
||||
/**
|
||||
* Every field stays correctable, `colonyId` included: a record filed against
|
||||
* the wrong colony is a data-entry error, and re-filing it is the only way to
|
||||
* repair the colony's history without deleting the record.
|
||||
*/
|
||||
export class UpdateInspectionDto extends PartialType(CreateInspectionDto) {}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateQueenEvaluationDto } from './create-queen-evaluation.dto';
|
||||
|
||||
/**
|
||||
* Breeding decisions are made from these scores, so a rating entered against
|
||||
* the wrong queen or with a slipped digit has to be correctable.
|
||||
*/
|
||||
export class UpdateQueenEvaluationDto extends PartialType(
|
||||
CreateQueenEvaluationDto,
|
||||
) {}
|
||||
@@ -16,6 +16,7 @@ import { CreateQueenDto } from './dto/create-queen.dto';
|
||||
import { UpdateQueenDto } from './dto/update-queen.dto';
|
||||
import { AssignColonyDto } from './dto/assign-colony.dto';
|
||||
import { CreateQueenEvaluationDto } from './dto/create-queen-evaluation.dto';
|
||||
import { UpdateQueenEvaluationDto } from './dto/update-queen-evaluation.dto';
|
||||
import { QueensService } from './queens.service';
|
||||
|
||||
@ApiTags('queens')
|
||||
@@ -81,6 +82,16 @@ export class QueensController {
|
||||
}
|
||||
|
||||
@RequirePermission('queens:evaluate')
|
||||
@RequirePermission('queens:evaluate')
|
||||
@Patch('evaluations/:evaluationId')
|
||||
updateEvaluation(
|
||||
@Param('evaluationId') evaluationId: string,
|
||||
@Body() dto: UpdateQueenEvaluationDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.queensService.updateEvaluation(evaluationId, dto, actor.id);
|
||||
}
|
||||
|
||||
@Post(':id/evaluations')
|
||||
addEvaluation(
|
||||
@Param('id') id: string,
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { QueensService } from './queens.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
@@ -8,8 +12,9 @@ import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
|
||||
describe('QueensService', () => {
|
||||
let service: QueensService;
|
||||
let tx: {
|
||||
queen: { update: jest.Mock };
|
||||
queenColonyHistory: { updateMany: jest.Mock; create: jest.Mock };
|
||||
colony: { update: jest.Mock };
|
||||
colony: { findUnique: jest.Mock; update: jest.Mock };
|
||||
};
|
||||
let prisma: {
|
||||
queen: {
|
||||
@@ -30,8 +35,12 @@ describe('QueensService', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
tx = {
|
||||
queen: { update: jest.fn().mockResolvedValue(queen) },
|
||||
queenColonyHistory: { updateMany: jest.fn(), create: jest.fn() },
|
||||
colony: { update: jest.fn() },
|
||||
colony: {
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
update: jest.fn(),
|
||||
},
|
||||
};
|
||||
prisma = {
|
||||
queen: {
|
||||
@@ -73,6 +82,17 @@ describe('QueensService', () => {
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('assignColony rejects a queen who is no longer available', async () => {
|
||||
prisma.queen.findUnique.mockResolvedValue({
|
||||
...queen,
|
||||
status: 'DECEASED',
|
||||
});
|
||||
await expect(
|
||||
service.assignColony('queen-1', { colonyId: 'colony-1' }, 'user-1'),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(prisma.colony.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('assignColony closes the previous queen history, sets currentQueenId, and opens a new history row', async () => {
|
||||
prisma.queen.findUnique.mockResolvedValue(queen);
|
||||
prisma.colony.findUnique.mockResolvedValue({
|
||||
@@ -163,6 +183,64 @@ describe('QueensService', () => {
|
||||
expect(prisma.queen.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('when a queen becomes unavailable', () => {
|
||||
it('clears currentQueenId and marks the colony QUEENLESS', async () => {
|
||||
prisma.queen.findUnique.mockResolvedValue(queen);
|
||||
tx.colony.findUnique.mockResolvedValue({
|
||||
id: 'colony-1',
|
||||
status: 'ACTIVE',
|
||||
});
|
||||
|
||||
await service.update('queen-1', { status: 'DECEASED' }, 'user-1');
|
||||
|
||||
expect(tx.colony.update).toHaveBeenCalledWith({
|
||||
where: { id: 'colony-1' },
|
||||
data: { currentQueenId: null, status: 'QUEENLESS' },
|
||||
});
|
||||
const [[closeArg]] = tx.queenColonyHistory.updateMany.mock.calls as [
|
||||
[{ where: { queenId: string; colonyId: string; endDate: null } }],
|
||||
];
|
||||
expect(closeArg.where).toEqual({
|
||||
queenId: 'queen-1',
|
||||
colonyId: 'colony-1',
|
||||
endDate: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('clears currentQueenId but leaves an already-terminal colony status alone', async () => {
|
||||
prisma.queen.findUnique.mockResolvedValue(queen);
|
||||
tx.colony.findUnique.mockResolvedValue({
|
||||
id: 'colony-1',
|
||||
status: 'DISSOLVED',
|
||||
});
|
||||
|
||||
await service.update('queen-1', { status: 'LOST' }, 'user-1');
|
||||
|
||||
expect(tx.colony.update).toHaveBeenCalledWith({
|
||||
where: { id: 'colony-1' },
|
||||
data: { currentQueenId: null, status: undefined },
|
||||
});
|
||||
});
|
||||
|
||||
it('does nothing when the queen is not currently assigned to a colony', async () => {
|
||||
prisma.queen.findUnique.mockResolvedValue(queen);
|
||||
tx.colony.findUnique.mockResolvedValue(null);
|
||||
|
||||
await service.update('queen-1', { status: 'SOLD' }, 'user-1');
|
||||
|
||||
expect(tx.colony.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not touch the colony for a status change that keeps the queen available', async () => {
|
||||
prisma.queen.findUnique.mockResolvedValue(queen);
|
||||
|
||||
await service.update('queen-1', { status: 'RESERVE' }, 'user-1');
|
||||
|
||||
expect(tx.colony.findUnique).not.toHaveBeenCalled();
|
||||
expect(tx.colony.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('getLineage builds the ancestor chain and the descendant tree', async () => {
|
||||
const grandmother = {
|
||||
id: 'queen-gm',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
@@ -12,6 +13,7 @@ import { CreateQueenDto } from './dto/create-queen.dto';
|
||||
import { UpdateQueenDto } from './dto/update-queen.dto';
|
||||
import { AssignColonyDto } from './dto/assign-colony.dto';
|
||||
import { CreateQueenEvaluationDto } from './dto/create-queen-evaluation.dto';
|
||||
import { UpdateQueenEvaluationDto } from './dto/update-queen-evaluation.dto';
|
||||
|
||||
export interface QueenFilter {
|
||||
status?: QueenStatus;
|
||||
@@ -40,6 +42,19 @@ export interface QueenLineage {
|
||||
|
||||
const MAX_LINEAGE_DEPTH = 8;
|
||||
|
||||
// Once a queen reaches one of these statuses she is no longer really "in"
|
||||
// whatever colony currently points at her - a colony's currentQueenId and
|
||||
// status would otherwise silently go stale (still showing a dead/lost
|
||||
// queen as current, never flagged QUEENLESS) until someone happens to
|
||||
// notice and fix it by hand.
|
||||
const QUEEN_NO_LONGER_AVAILABLE_STATUSES: QueenStatus[] = [
|
||||
'LOST',
|
||||
'DECEASED',
|
||||
'REPLACED',
|
||||
'SOLD',
|
||||
];
|
||||
const TERMINAL_COLONY_STATUSES = ['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED'];
|
||||
|
||||
@Injectable()
|
||||
export class QueensService {
|
||||
constructor(
|
||||
@@ -92,10 +107,38 @@ export class QueensService {
|
||||
}
|
||||
await this.assertMotherQueenExists(dto.motherQueenId);
|
||||
}
|
||||
const queen = await this.prisma.queen.update({
|
||||
const becameUnavailable =
|
||||
dto.status !== undefined &&
|
||||
dto.status !== before.status &&
|
||||
QUEEN_NO_LONGER_AVAILABLE_STATUSES.includes(dto.status);
|
||||
|
||||
const queen = await this.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.queen.update({
|
||||
where: { id },
|
||||
data: { ...dto },
|
||||
});
|
||||
if (becameUnavailable) {
|
||||
const colony = await tx.colony.findUnique({
|
||||
where: { currentQueenId: id },
|
||||
});
|
||||
if (colony) {
|
||||
await tx.colony.update({
|
||||
where: { id: colony.id },
|
||||
data: {
|
||||
currentQueenId: null,
|
||||
status: TERMINAL_COLONY_STATUSES.includes(colony.status)
|
||||
? undefined
|
||||
: 'QUEENLESS',
|
||||
},
|
||||
});
|
||||
await tx.queenColonyHistory.updateMany({
|
||||
where: { queenId: id, colonyId: colony.id, endDate: null },
|
||||
data: { endDate: new Date() },
|
||||
});
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'Queen',
|
||||
entityId: id,
|
||||
@@ -113,6 +156,11 @@ export class QueensService {
|
||||
actingUserId: string,
|
||||
): Promise<Queen> {
|
||||
const queen = await this.findById(id);
|
||||
if (QUEEN_NO_LONGER_AVAILABLE_STATUSES.includes(queen.status)) {
|
||||
throw new ConflictException(
|
||||
'Die Königin ist nicht mehr verfügbar für eine Zuweisung - korrigiere zuerst ihren Status, falls dies ein Versehen war',
|
||||
);
|
||||
}
|
||||
const colony = await this.prisma.colony.findUnique({
|
||||
where: { id: dto.colonyId },
|
||||
});
|
||||
@@ -180,6 +228,32 @@ export class QueensService {
|
||||
});
|
||||
}
|
||||
|
||||
async updateEvaluation(
|
||||
evaluationId: string,
|
||||
dto: UpdateQueenEvaluationDto,
|
||||
actingUserId: string,
|
||||
) {
|
||||
const before = await this.prisma.queenEvaluation.findUnique({
|
||||
where: { id: evaluationId },
|
||||
});
|
||||
if (!before) {
|
||||
throw new NotFoundException('Queen evaluation not found');
|
||||
}
|
||||
const evaluation = await this.prisma.queenEvaluation.update({
|
||||
where: { id: evaluationId },
|
||||
data: { ...dto, date: dto.date ? new Date(dto.date) : undefined },
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'QueenEvaluation',
|
||||
entityId: evaluationId,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: evaluation,
|
||||
});
|
||||
return evaluation;
|
||||
}
|
||||
|
||||
async addEvaluation(
|
||||
id: string,
|
||||
dto: CreateQueenEvaluationDto,
|
||||
|
||||
@@ -15,6 +15,18 @@ import {
|
||||
const CATALOG_KEYS = PERMISSION_CATALOG.map((entry) => entry.key);
|
||||
|
||||
export class UpdateRoleDto {
|
||||
/**
|
||||
* Correctable like every other field. System roles are out of reach anyway:
|
||||
* RolesService.update rejects them outright, which also keeps the seed's
|
||||
* lookup of the ADMIN role by key intact.
|
||||
*/
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(50)
|
||||
key?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -158,7 +158,7 @@ export class RolesService {
|
||||
const role = await this.prisma.$transaction(async (tx) => {
|
||||
await tx.role.update({
|
||||
where: { id },
|
||||
data: { name: dto.name, description: dto.description },
|
||||
data: { key: dto.key, name: dto.name, description: dto.description },
|
||||
});
|
||||
if (dto.permissionKeys) {
|
||||
await this.replacePermissions(tx, id, dto.permissionKeys);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsNumber, IsOptional, IsString } from 'class-validator';
|
||||
import { IsEnum, IsNumber, IsOptional, IsString, Min } from 'class-validator';
|
||||
import { StockCategory } from '../../../generated/prisma';
|
||||
|
||||
export class CreateStockItemDto {
|
||||
@@ -18,11 +18,13 @@ export class CreateStockItemDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
quantity?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
minThreshold?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
|
||||
@@ -7,7 +7,11 @@ export class CreateStockMovementDto {
|
||||
@IsEnum(StockMovementType)
|
||||
type!: StockMovementType;
|
||||
|
||||
@ApiProperty()
|
||||
@ApiProperty({
|
||||
description:
|
||||
'Für IN/OUT immer positiv angeben - die Richtung ergibt sich aus "type". ' +
|
||||
'Nur ADJUSTMENT erlaubt einen negativen Wert, um den Bestand direkt zu korrigieren.',
|
||||
})
|
||||
@IsNumber()
|
||||
quantity!: number;
|
||||
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { PartialType, PickType } from '@nestjs/swagger';
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateStockItemDto } from './create-stock-item.dto';
|
||||
|
||||
export class UpdateStockItemDto extends PartialType(
|
||||
PickType(CreateStockItemDto, [
|
||||
'name',
|
||||
'category',
|
||||
'unit',
|
||||
'minThreshold',
|
||||
'notes',
|
||||
] as const),
|
||||
) {}
|
||||
/**
|
||||
* `quantity` is editable here, not only through stock movements: a stock level
|
||||
* typed in wrong has to be correctable directly. The service books the
|
||||
* difference as an ADJUSTMENT movement, so the movement history still adds up
|
||||
* to the stored quantity and nothing is corrected behind its back.
|
||||
*/
|
||||
export class UpdateStockItemDto extends PartialType(CreateStockItemDto) {}
|
||||
@@ -3,6 +3,7 @@ import { CreateStorageContainerDto } from './create-storage-container.dto';
|
||||
|
||||
export class UpdateStorageContainerDto extends PartialType(
|
||||
PickType(CreateStorageContainerDto, [
|
||||
'code',
|
||||
'containerType',
|
||||
'capacity',
|
||||
'location',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { StorageService } from './storage.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
@@ -100,4 +100,60 @@ describe('StorageService', () => {
|
||||
|
||||
expect(tx.stockItem.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('addMovement rejects a negative quantity for OUT instead of silently adding stock', async () => {
|
||||
prisma.stockItem.findUnique.mockResolvedValue(stockItem);
|
||||
|
||||
await expect(
|
||||
service.addMovement('item-1', { type: 'OUT', quantity: -3 }, 'user-1'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(tx.stockItem.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('addMovement rejects a negative quantity for IN', async () => {
|
||||
prisma.stockItem.findUnique.mockResolvedValue(stockItem);
|
||||
|
||||
await expect(
|
||||
service.addMovement('item-1', { type: 'IN', quantity: -3 }, 'user-1'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('addMovement allows a negative quantity for ADJUSTMENT', async () => {
|
||||
prisma.stockItem.findUnique.mockResolvedValue(stockItem);
|
||||
|
||||
await service.addMovement(
|
||||
'item-1',
|
||||
{ type: 'ADJUSTMENT', quantity: -3 },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
const [[updateArg]] = tx.stockItem.update.mock.calls as [
|
||||
[{ data: { quantity: { increment: number } } }],
|
||||
];
|
||||
expect(updateArg.data.quantity.increment).toBe(-3);
|
||||
});
|
||||
|
||||
it('books the difference as an ADJUSTMENT movement when the stock quantity is corrected', async () => {
|
||||
prisma.stockItem.findUnique.mockResolvedValue(stockItem);
|
||||
|
||||
await service.updateStockItem('item-1', { quantity: 7 }, 'user-1');
|
||||
|
||||
expect(tx.stockMovement.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
stockItemId: 'item-1',
|
||||
type: 'ADJUSTMENT',
|
||||
quantity: -3,
|
||||
userId: 'user-1',
|
||||
notes: 'Korrektur über Artikel bearbeiten',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('books no movement when an edit leaves the stock quantity untouched', async () => {
|
||||
prisma.stockItem.findUnique.mockResolvedValue(stockItem);
|
||||
|
||||
await service.updateStockItem('item-1', { name: 'Futtersirup' }, 'user-1');
|
||||
|
||||
expect(tx.stockMovement.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,8 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||
@@ -102,10 +106,30 @@ export class StorageService {
|
||||
actingUserId: string,
|
||||
): Promise<StockItem> {
|
||||
const before = await this.findStockItemById(id);
|
||||
const stockItem = await this.prisma.stockItem.update({
|
||||
const correction =
|
||||
dto.quantity === undefined ? 0 : dto.quantity - before.quantity;
|
||||
|
||||
const stockItem = await this.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.stockItem.update({
|
||||
where: { id },
|
||||
data: { ...dto },
|
||||
});
|
||||
// Booking the difference keeps the movement history consistent with the
|
||||
// stored quantity - without it, a corrected stock level would no longer
|
||||
// be the sum of its movements.
|
||||
if (correction !== 0) {
|
||||
await tx.stockMovement.create({
|
||||
data: {
|
||||
stockItemId: id,
|
||||
type: 'ADJUSTMENT',
|
||||
quantity: correction,
|
||||
userId: actingUserId,
|
||||
notes: 'Korrektur über Artikel bearbeiten',
|
||||
},
|
||||
});
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'StockItem',
|
||||
entityId: id,
|
||||
@@ -123,6 +147,16 @@ export class StorageService {
|
||||
actingUserId: string,
|
||||
) {
|
||||
await this.findStockItemById(id);
|
||||
// IN/OUT direction comes entirely from `type`, which then negates
|
||||
// quantity for OUT below - a negative quantity submitted for OUT would
|
||||
// therefore silently ADD stock while being logged as a removal,
|
||||
// inverting the audit trail. Only ADJUSTMENT may be negative, since
|
||||
// that is the only way to correct a stock count downward.
|
||||
if (dto.type !== 'ADJUSTMENT' && dto.quantity <= 0) {
|
||||
throw new BadRequestException(
|
||||
'Menge muss bei Eingang/Ausgang positiv sein - die Richtung wird durch den Bewegungstyp bestimmt',
|
||||
);
|
||||
}
|
||||
const delta =
|
||||
dto.type === 'IN' || dto.type === 'ADJUSTMENT'
|
||||
? dto.quantity
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { PartialType, PickType } from '@nestjs/swagger';
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateSwarmControlCheckDto } from './create-swarm-control-check.dto';
|
||||
|
||||
/**
|
||||
* Every field stays correctable, `colonyId` included: a record filed against
|
||||
* the wrong colony is a data-entry error, and re-filing it is the only way to
|
||||
* repair the colony's history without deleting the record.
|
||||
*/
|
||||
export class UpdateSwarmControlCheckDto extends PartialType(
|
||||
PickType(CreateSwarmControlCheckDto, [
|
||||
'date',
|
||||
'queenCellsFound',
|
||||
'queenCellsCapped',
|
||||
'swarmMood',
|
||||
'actionTaken',
|
||||
'notes',
|
||||
'nextCheckDate',
|
||||
] as const),
|
||||
CreateSwarmControlCheckDto,
|
||||
) {}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsDateString, IsEnum, IsOptional, IsString } from 'class-validator';
|
||||
import { TaskPriority, TaskStatus } from '../../../generated/prisma';
|
||||
import {
|
||||
TaskPriority,
|
||||
TaskRelatedType,
|
||||
TaskStatus,
|
||||
} from '../../../generated/prisma';
|
||||
|
||||
export class UpdateTaskDto {
|
||||
@ApiPropertyOptional()
|
||||
@@ -32,4 +36,14 @@ export class UpdateTaskDto {
|
||||
@IsOptional()
|
||||
@IsEnum(TaskStatus)
|
||||
status?: TaskStatus;
|
||||
|
||||
@ApiPropertyOptional({ enum: TaskRelatedType })
|
||||
@IsOptional()
|
||||
@IsEnum(TaskRelatedType)
|
||||
relatedType?: TaskRelatedType;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
relatedId?: string;
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsDateString, IsOptional, IsString } from 'class-validator';
|
||||
import {
|
||||
IsDateString,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateTreatmentDto {
|
||||
@ApiProperty()
|
||||
@@ -53,4 +59,13 @@ export class CreateTreatmentDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Wartezeit in Tagen ab Behandlungsende, bevor Honig aus diesem Volk geerntet werden darf.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
withdrawalPeriodDays?: number;
|
||||
}
|
||||
@@ -1,17 +1,9 @@
|
||||
import { PartialType, PickType } from '@nestjs/swagger';
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateTreatmentDto } from './create-treatment.dto';
|
||||
|
||||
export class UpdateTreatmentDto extends PartialType(
|
||||
PickType(CreateTreatmentDto, [
|
||||
'date',
|
||||
'startDate',
|
||||
'endDate',
|
||||
'product',
|
||||
'activeIngredient',
|
||||
'amount',
|
||||
'method',
|
||||
'reason',
|
||||
'batchNumber',
|
||||
'notes',
|
||||
] as const),
|
||||
) {}
|
||||
/**
|
||||
* Every field stays correctable, `colonyId` included: a record filed against
|
||||
* the wrong colony is a data-entry error, and re-filing it is the only way to
|
||||
* repair the colony's history without deleting the record.
|
||||
*/
|
||||
export class UpdateTreatmentDto extends PartialType(CreateTreatmentDto) {}
|
||||
@@ -1,4 +1,12 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
@@ -19,6 +27,13 @@ export class TreatmentsController {
|
||||
return this.treatmentsService.findAll();
|
||||
}
|
||||
|
||||
@RequirePermission('treatments:read')
|
||||
@Get('withdrawal-status')
|
||||
getWithdrawalStatus(@Query('colonyIds') colonyIds?: string) {
|
||||
const ids = colonyIds ? colonyIds.split(',').filter(Boolean) : [];
|
||||
return this.treatmentsService.getWithdrawalStatus(ids);
|
||||
}
|
||||
|
||||
@RequirePermission('treatments:read')
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
|
||||
@@ -97,4 +97,58 @@ describe('TreatmentsService', () => {
|
||||
expect(queryArg.where.colonyId).toBe('colony-1');
|
||||
expect(queryArg.orderBy.date).toBe('asc');
|
||||
});
|
||||
|
||||
describe('getWithdrawalStatus', () => {
|
||||
it('returns an empty array without querying when no colony ids are given', async () => {
|
||||
const result = await service.getWithdrawalStatus([]);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(prisma.treatment.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports the treatment whose withdrawal window reaches furthest into the future', async () => {
|
||||
const now = Date.now();
|
||||
const inTenDays = new Date(now + 10 * 24 * 60 * 60 * 1000);
|
||||
const inFiveDays = new Date(now + 5 * 24 * 60 * 60 * 1000);
|
||||
prisma.treatment.findMany.mockResolvedValue([
|
||||
{
|
||||
colonyId: 'colony-1',
|
||||
date: inTenDays,
|
||||
endDate: null,
|
||||
withdrawalPeriodDays: 0,
|
||||
product: 'Ameisensäure',
|
||||
},
|
||||
{
|
||||
colonyId: 'colony-1',
|
||||
date: inFiveDays,
|
||||
endDate: null,
|
||||
withdrawalPeriodDays: 21,
|
||||
product: 'Api Bioxal',
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await service.getWithdrawalStatus(['colony-1']);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].colonyId).toBe('colony-1');
|
||||
expect(result[0].product).toBe('Api Bioxal');
|
||||
});
|
||||
|
||||
it('omits a colony whose withdrawal window has already passed', async () => {
|
||||
const longAgo = new Date('2020-01-01');
|
||||
prisma.treatment.findMany.mockResolvedValue([
|
||||
{
|
||||
colonyId: 'colony-1',
|
||||
date: longAgo,
|
||||
endDate: null,
|
||||
withdrawalPeriodDays: 14,
|
||||
product: 'Ameisensäure',
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await service.getWithdrawalStatus(['colony-1']);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,12 @@ import { Treatment } from '../../generated/prisma';
|
||||
import { CreateTreatmentDto } from './dto/create-treatment.dto';
|
||||
import { UpdateTreatmentDto } from './dto/update-treatment.dto';
|
||||
|
||||
export interface WithdrawalStatus {
|
||||
colonyId: string;
|
||||
activeUntil: string;
|
||||
product: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TreatmentsService {
|
||||
constructor(
|
||||
@@ -33,6 +39,44 @@ export class TreatmentsService {
|
||||
});
|
||||
}
|
||||
|
||||
// For each colony, returns the treatment whose withdrawal window (its
|
||||
// end date, or start date if the treatment is still ongoing, plus its
|
||||
// withdrawalPeriodDays) reaches furthest into the future - only when
|
||||
// that window is still active. Colonies with no active window are
|
||||
// omitted rather than returned as null, since most colonies have none.
|
||||
async getWithdrawalStatus(colonyIds: string[]): Promise<WithdrawalStatus[]> {
|
||||
if (colonyIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const treatments = await this.prisma.treatment.findMany({
|
||||
where: {
|
||||
colonyId: { in: colonyIds },
|
||||
withdrawalPeriodDays: { not: null },
|
||||
},
|
||||
});
|
||||
const now = new Date();
|
||||
const activeByColony = new Map<string, WithdrawalStatus>();
|
||||
for (const treatment of treatments) {
|
||||
const referenceDate = treatment.endDate ?? treatment.date;
|
||||
const activeUntil = new Date(referenceDate);
|
||||
activeUntil.setUTCDate(
|
||||
activeUntil.getUTCDate() + (treatment.withdrawalPeriodDays ?? 0),
|
||||
);
|
||||
if (activeUntil <= now) {
|
||||
continue;
|
||||
}
|
||||
const current = activeByColony.get(treatment.colonyId);
|
||||
if (!current || activeUntil > new Date(current.activeUntil)) {
|
||||
activeByColony.set(treatment.colonyId, {
|
||||
colonyId: treatment.colonyId,
|
||||
activeUntil: activeUntil.toISOString(),
|
||||
product: treatment.product,
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...activeByColony.values()];
|
||||
}
|
||||
|
||||
async create(
|
||||
dto: CreateTreatmentDto,
|
||||
actingUserId: string,
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, MinLength } from 'class-validator';
|
||||
|
||||
/**
|
||||
* An administrator setting a new password for someone else. There is no
|
||||
* self-service password-reset flow, so without this a user who forgets their
|
||||
* password is locked out of the account for good - the one correction the
|
||||
* generic user update cannot make, since the password is never readable.
|
||||
*/
|
||||
export class ResetUserPasswordDto {
|
||||
@ApiProperty({ minLength: 8 })
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password!: string;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, MinLength } from 'class-validator';
|
||||
import { IsEmail, IsOptional, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class UpdateUserDto {
|
||||
@ApiPropertyOptional()
|
||||
@@ -7,6 +7,18 @@ export class UpdateUserDto {
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
/**
|
||||
* The e-mail is the login identity, so changing it changes which address
|
||||
* signs in as this account. It is still correctable: a typo would otherwise
|
||||
* lock the account out for good, and there is no password-reset flow that a
|
||||
* changed address could be abused to trigger. A duplicate is rejected by the
|
||||
* unique constraint and surfaces as a 409.
|
||||
*/
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
email?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Id of an active Role' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -5,6 +5,7 @@ import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||
import { CreateUserDto } from './dto/create-user.dto';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
import { ResetUserPasswordDto } from './dto/reset-user-password.dto';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
@ApiTags('users')
|
||||
@@ -53,6 +54,16 @@ export class UsersController {
|
||||
return this.usersService.activate(id, actor.id);
|
||||
}
|
||||
|
||||
@RequirePermission('users:update')
|
||||
@Post(':id/reset-password')
|
||||
resetPassword(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: ResetUserPasswordDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.usersService.resetPassword(id, dto, actor.id);
|
||||
}
|
||||
|
||||
@RequirePermission('users:update')
|
||||
@Post(':id/mfa/reset')
|
||||
resetMfa(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
|
||||
|
||||
@@ -21,6 +21,7 @@ describe('UsersService', () => {
|
||||
role: {
|
||||
findUnique: jest.Mock;
|
||||
};
|
||||
refreshToken: { updateMany: jest.Mock };
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
let permissionResolver: {
|
||||
@@ -77,6 +78,7 @@ describe('UsersService', () => {
|
||||
role: {
|
||||
findUnique: jest.fn().mockResolvedValue(staffRole),
|
||||
},
|
||||
refreshToken: { updateMany: jest.fn() },
|
||||
};
|
||||
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||
permissionResolver = {
|
||||
@@ -379,4 +381,53 @@ describe('UsersService', () => {
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('resetPassword', () => {
|
||||
it('revokes every refresh token and clears the permission cache', async () => {
|
||||
prisma.user.findUnique.mockResolvedValue(existingUser);
|
||||
prisma.user.update.mockResolvedValue(existingUser);
|
||||
|
||||
await usersService.resetPassword(
|
||||
'user-1',
|
||||
{ password: 'neuesPasswort1' },
|
||||
'user-admin',
|
||||
);
|
||||
|
||||
expect(prisma.refreshToken.updateMany).toHaveBeenCalledWith({
|
||||
where: { userId: 'user-1', revokedAt: null },
|
||||
data: { revokedAt: expect.any(Date) as Date },
|
||||
});
|
||||
expect(permissionResolver.invalidateUser).toHaveBeenCalledWith('user-1');
|
||||
});
|
||||
|
||||
it('stores a hash, never the password itself', async () => {
|
||||
prisma.user.findUnique.mockResolvedValue(existingUser);
|
||||
prisma.user.update.mockResolvedValue(existingUser);
|
||||
|
||||
await usersService.resetPassword(
|
||||
'user-1',
|
||||
{ password: 'neuesPasswort1' },
|
||||
'user-admin',
|
||||
);
|
||||
|
||||
const [[updateArgs]] = prisma.user.update.mock.calls as [
|
||||
[{ data: { passwordHash: string } }],
|
||||
];
|
||||
expect(updateArgs.data.passwordHash).not.toBe('neuesPasswort1');
|
||||
expect(updateArgs.data.passwordHash).toMatch(/^\$2[aby]\$/);
|
||||
});
|
||||
|
||||
it('refuses to reset your own password, which belongs to /profile/password', async () => {
|
||||
prisma.user.findUnique.mockResolvedValue(existingUser);
|
||||
|
||||
await expect(
|
||||
usersService.resetPassword(
|
||||
'user-1',
|
||||
{ password: 'neuesPasswort1' },
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import { PermissionResolverService } from '../auth/permissions/permission-resolv
|
||||
import { Prisma } from '../../generated/prisma';
|
||||
import { CreateUserDto } from './dto/create-user.dto';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
import { ResetUserPasswordDto } from './dto/reset-user-password.dto';
|
||||
|
||||
const BCRYPT_SALT_ROUNDS = 12;
|
||||
|
||||
@@ -194,6 +195,7 @@ export class UsersService {
|
||||
where: { id },
|
||||
data: {
|
||||
name: dto.name,
|
||||
email: dto.email,
|
||||
roleId: dto.roleId,
|
||||
},
|
||||
...USER_WITH_ROLE,
|
||||
@@ -240,6 +242,49 @@ export class UsersService {
|
||||
* a stolen access token alone (no password) would be enough to strip the
|
||||
* victim's own MFA protection via this admin route.
|
||||
*/
|
||||
/**
|
||||
* Sets a new password for another user. Mirrors ProfileService.changePassword
|
||||
* in what it invalidates: every refresh token is revoked and the permission
|
||||
* cache is cleared, so sessions opened with the old password stop working at
|
||||
* once rather than living on until their access token expires.
|
||||
*
|
||||
* Your own password goes through /profile/password, which requires the
|
||||
* current one - otherwise anyone holding users:update could silently take
|
||||
* over their own account's credentials without proving they own it.
|
||||
*/
|
||||
async resetPassword(
|
||||
id: string,
|
||||
dto: ResetUserPasswordDto,
|
||||
actingUserId?: string,
|
||||
): Promise<UserSummary> {
|
||||
if (actingUserId && id === actingUserId) {
|
||||
throw new ForbiddenException(
|
||||
'Use /profile/password to change your own password',
|
||||
);
|
||||
}
|
||||
await this.findById(id);
|
||||
const passwordHash = await bcrypt.hash(dto.password, BCRYPT_SALT_ROUNDS);
|
||||
const user = await this.prisma.user.update({
|
||||
where: { id },
|
||||
data: { passwordHash, passwordChangedAt: new Date() },
|
||||
...USER_WITH_ROLE,
|
||||
});
|
||||
await this.prisma.refreshToken.updateMany({
|
||||
where: { userId: id, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
this.permissionResolver.invalidateUser(id);
|
||||
// The new password never reaches the audit log - only the fact that it
|
||||
// was replaced, and by whom.
|
||||
await this.auditLog.record({
|
||||
entityType: 'User',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
});
|
||||
return this.toSummary(user);
|
||||
}
|
||||
|
||||
async resetMfa(id: string, actingUserId?: string): Promise<UserSummary> {
|
||||
if (actingUserId && id === actingUserId) {
|
||||
throw new ForbiddenException(
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { VarroaMethod } from '../../../generated/prisma';
|
||||
|
||||
@@ -25,16 +26,19 @@ export class CreateVarroaMeasurementDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
durationMinutes?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
miteCount?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
calculatedValue?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { PartialType, PickType } from '@nestjs/swagger';
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateVarroaMeasurementDto } from './create-varroa-measurement.dto';
|
||||
|
||||
/**
|
||||
* Every field stays correctable, `colonyId` included: a record filed against
|
||||
* the wrong colony is a data-entry error, and re-filing it is the only way to
|
||||
* repair the colony's history without deleting the record.
|
||||
*/
|
||||
export class UpdateVarroaMeasurementDto extends PartialType(
|
||||
PickType(CreateVarroaMeasurementDto, [
|
||||
'date',
|
||||
'method',
|
||||
'durationMinutes',
|
||||
'miteCount',
|
||||
'calculatedValue',
|
||||
'assessment',
|
||||
'notes',
|
||||
] as const),
|
||||
CreateVarroaMeasurementDto,
|
||||
) {}
|
||||
@@ -100,4 +100,106 @@ describe('VarroaService', () => {
|
||||
expect(queryArg.where.colonyId).toBe('colony-1');
|
||||
expect(queryArg.orderBy.date).toBe('asc');
|
||||
});
|
||||
|
||||
describe('calculatedValue auto-computation', () => {
|
||||
it('computes mites/day for a natural mite fall count on create', async () => {
|
||||
prisma.varroaMeasurement.create.mockResolvedValue(measurement);
|
||||
|
||||
await service.create(
|
||||
{
|
||||
colonyId: 'colony-1',
|
||||
date: '2026-01-01',
|
||||
method: 'NATURAL_MITE_FALL',
|
||||
miteCount: 45,
|
||||
durationMinutes: 3 * 24 * 60,
|
||||
},
|
||||
'user-1',
|
||||
);
|
||||
|
||||
const [[createArg]] = prisma.varroaMeasurement.create.mock.calls as [
|
||||
[{ data: { calculatedValue?: number } }],
|
||||
];
|
||||
expect(createArg.data.calculatedValue).toBe(15);
|
||||
});
|
||||
|
||||
it('does not compute calculatedValue for a non-time-based method', async () => {
|
||||
prisma.varroaMeasurement.create.mockResolvedValue(measurement);
|
||||
|
||||
await service.create(
|
||||
{
|
||||
colonyId: 'colony-1',
|
||||
date: '2026-01-01',
|
||||
method: 'POWDERED_SUGAR',
|
||||
miteCount: 8,
|
||||
},
|
||||
'user-1',
|
||||
);
|
||||
|
||||
const [[createArg]] = prisma.varroaMeasurement.create.mock.calls as [
|
||||
[{ data: { calculatedValue?: number } }],
|
||||
];
|
||||
expect(createArg.data.calculatedValue).toBeUndefined();
|
||||
});
|
||||
|
||||
it('respects an explicitly provided calculatedValue instead of computing one', async () => {
|
||||
prisma.varroaMeasurement.create.mockResolvedValue(measurement);
|
||||
|
||||
await service.create(
|
||||
{
|
||||
colonyId: 'colony-1',
|
||||
date: '2026-01-01',
|
||||
method: 'NATURAL_MITE_FALL',
|
||||
miteCount: 45,
|
||||
durationMinutes: 3 * 24 * 60,
|
||||
calculatedValue: 99,
|
||||
},
|
||||
'user-1',
|
||||
);
|
||||
|
||||
const [[createArg]] = prisma.varroaMeasurement.create.mock.calls as [
|
||||
[{ data: { calculatedValue?: number } }],
|
||||
];
|
||||
expect(createArg.data.calculatedValue).toBe(99);
|
||||
});
|
||||
|
||||
it('backfills calculatedValue on update when it was never set', async () => {
|
||||
prisma.varroaMeasurement.findUnique.mockResolvedValue({
|
||||
...measurement,
|
||||
method: 'NATURAL_MITE_FALL',
|
||||
miteCount: null,
|
||||
durationMinutes: null,
|
||||
calculatedValue: null,
|
||||
});
|
||||
prisma.varroaMeasurement.update.mockResolvedValue(measurement);
|
||||
|
||||
await service.update(
|
||||
'varroa-1',
|
||||
{ miteCount: 20, durationMinutes: 24 * 60 },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
const [[updateArg]] = prisma.varroaMeasurement.update.mock.calls as [
|
||||
[{ data: { calculatedValue?: number } }],
|
||||
];
|
||||
expect(updateArg.data.calculatedValue).toBe(20);
|
||||
});
|
||||
|
||||
it('never overwrites an already-stored calculatedValue on an unrelated edit', async () => {
|
||||
prisma.varroaMeasurement.findUnique.mockResolvedValue({
|
||||
...measurement,
|
||||
method: 'NATURAL_MITE_FALL',
|
||||
miteCount: 45,
|
||||
durationMinutes: 3 * 24 * 60,
|
||||
calculatedValue: 15,
|
||||
});
|
||||
prisma.varroaMeasurement.update.mockResolvedValue(measurement);
|
||||
|
||||
await service.update('varroa-1', { assessment: 'stabil' }, 'user-1');
|
||||
|
||||
const [[updateArg]] = prisma.varroaMeasurement.update.mock.calls as [
|
||||
[{ data: { calculatedValue?: number } }],
|
||||
];
|
||||
expect(updateArg.data.calculatedValue).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,30 @@ import { VarroaMeasurement } from '../../generated/prisma';
|
||||
import { CreateVarroaMeasurementDto } from './dto/create-varroa-measurement.dto';
|
||||
import { UpdateVarroaMeasurementDto } from './dto/update-varroa-measurement.dto';
|
||||
|
||||
const MINUTES_PER_DAY = 24 * 60;
|
||||
|
||||
// Natural mite fall is counted on a sticky-board insert left in the hive
|
||||
// for some duration and is conventionally normalized to a daily rate
|
||||
// ("Milben/Tag") - both the VARROA_THRESHOLD automation config
|
||||
// (thresholdMitesPerDay) and the annual PDF report already assume
|
||||
// calculatedValue is in that unit. The other methods (Puderzucker,
|
||||
// Waschmethode, Drohnenbrutkontrolle) report a different, non-time-based
|
||||
// metric (mites per fixed bee sample); there is no single verified
|
||||
// formula for those here, so calculatedValue is left for the user to
|
||||
// enter manually for those methods instead of guessing one.
|
||||
function computeNaturalMiteFallPerDay(
|
||||
miteCount: number | null | undefined,
|
||||
durationMinutes: number | null | undefined,
|
||||
): number | undefined {
|
||||
if (miteCount === null || miteCount === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (!durationMinutes || durationMinutes <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
return (miteCount / durationMinutes) * MINUTES_PER_DAY;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class VarroaService {
|
||||
constructor(
|
||||
@@ -41,8 +65,18 @@ export class VarroaService {
|
||||
dto: CreateVarroaMeasurementDto,
|
||||
actingUserId: string,
|
||||
): Promise<VarroaMeasurement> {
|
||||
const calculatedValue =
|
||||
dto.calculatedValue ??
|
||||
(dto.method === 'NATURAL_MITE_FALL'
|
||||
? computeNaturalMiteFallPerDay(dto.miteCount, dto.durationMinutes)
|
||||
: undefined);
|
||||
const measurement = await this.prisma.varroaMeasurement.create({
|
||||
data: { ...dto, date: new Date(dto.date), userId: actingUserId },
|
||||
data: {
|
||||
...dto,
|
||||
calculatedValue,
|
||||
date: new Date(dto.date),
|
||||
userId: actingUserId,
|
||||
},
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'VarroaMeasurement',
|
||||
@@ -61,9 +95,27 @@ export class VarroaService {
|
||||
actingUserId: string,
|
||||
): Promise<VarroaMeasurement> {
|
||||
const before = await this.findById(id);
|
||||
// Only auto-fill calculatedValue when nothing has set it yet - never
|
||||
// overwrite a value the user (or a prior auto-fill) already stored,
|
||||
// even if this edit only touches an unrelated field like notes.
|
||||
const shouldAutoCompute =
|
||||
dto.calculatedValue === undefined && before.calculatedValue === null;
|
||||
const method = dto.method ?? before.method;
|
||||
const calculatedValue =
|
||||
dto.calculatedValue ??
|
||||
(shouldAutoCompute && method === 'NATURAL_MITE_FALL'
|
||||
? computeNaturalMiteFallPerDay(
|
||||
dto.miteCount ?? before.miteCount,
|
||||
dto.durationMinutes ?? before.durationMinutes,
|
||||
)
|
||||
: undefined);
|
||||
const measurement = await this.prisma.varroaMeasurement.update({
|
||||
where: { id },
|
||||
data: { ...dto, date: dto.date ? new Date(dto.date) : undefined },
|
||||
data: {
|
||||
...dto,
|
||||
calculatedValue,
|
||||
date: dto.date ? new Date(dto.date) : undefined,
|
||||
},
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'VarroaMeasurement',
|
||||
|
||||
@@ -8,6 +8,7 @@ function openMeteoResponse(
|
||||
tempMax: number | null;
|
||||
tempMin: number | null;
|
||||
precipitation: number | null;
|
||||
wind?: number | null;
|
||||
}[],
|
||||
): unknown {
|
||||
return {
|
||||
@@ -16,6 +17,7 @@ function openMeteoResponse(
|
||||
temperature_2m_max: days.map((day) => day.tempMax),
|
||||
temperature_2m_min: days.map((day) => day.tempMin),
|
||||
precipitation_sum: days.map((day) => day.precipitation),
|
||||
wind_speed_10m_max: days.map((day) => day.wind ?? 10),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -83,13 +85,68 @@ describe('WeatherService', () => {
|
||||
const result = await weatherService.getWeather('apiary-1');
|
||||
|
||||
expect(result.daily).toEqual([
|
||||
{ date: '2026-06-01', tempMaxC: 22, tempMinC: 12, precipitationMm: 0 },
|
||||
{
|
||||
date: '2026-06-01',
|
||||
tempMaxC: 22,
|
||||
tempMinC: 12,
|
||||
precipitationMm: 0,
|
||||
windSpeedMaxKmh: 10,
|
||||
inspectionSuitable: true,
|
||||
},
|
||||
]);
|
||||
const [url] = fetchMock.mock.calls[0] as [URL];
|
||||
expect(url.toString()).toContain('latitude=52.5');
|
||||
expect(url.toString()).toContain('longitude=13.4');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['too cold', { tempMax: 10, tempMin: 4, precipitation: 0, wind: 5 }],
|
||||
['raining', { tempMax: 20, tempMin: 12, precipitation: 3, wind: 5 }],
|
||||
['too windy', { tempMax: 20, tempMin: 12, precipitation: 0, wind: 40 }],
|
||||
])('marks a day unsuitable for inspection when %s', async (_label, day) => {
|
||||
prisma.apiary.findUnique.mockResolvedValue({
|
||||
latitude: 52.5,
|
||||
longitude: 13.4,
|
||||
});
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify(openMeteoResponse([{ date: '2026-06-01', ...day }])),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
|
||||
const result = await weatherService.getWeather('apiary-1');
|
||||
|
||||
expect(result.daily[0].inspectionSuitable).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves inspectionSuitable null when a required value is missing', async () => {
|
||||
prisma.apiary.findUnique.mockResolvedValue({
|
||||
latitude: 52.5,
|
||||
longitude: 13.4,
|
||||
});
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify(
|
||||
openMeteoResponse([
|
||||
{
|
||||
date: '2026-06-01',
|
||||
tempMax: null,
|
||||
tempMin: null,
|
||||
precipitation: 0,
|
||||
wind: 5,
|
||||
},
|
||||
]),
|
||||
),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
|
||||
const result = await weatherService.getWeather('apiary-1');
|
||||
|
||||
expect(result.daily[0].inspectionSuitable).toBeNull();
|
||||
});
|
||||
|
||||
it('throws BadRequestException when Open-Meteo responds with an error status', async () => {
|
||||
prisma.apiary.findUnique.mockResolvedValue({
|
||||
latitude: 52.5,
|
||||
|
||||
@@ -15,11 +15,23 @@ const GOOD_PRECIPITATION_THRESHOLD_MM = 15;
|
||||
const POOR_TEMP_THRESHOLD_C = 12;
|
||||
const POOR_PRECIPITATION_THRESHOLD_MM = 30;
|
||||
|
||||
// Rule-of-thumb minimums for opening a hive without unduly stressing the
|
||||
// colony or making the inspection unpleasant/risky for the beekeeper:
|
||||
// below ~14-15°C bees cluster and brood chills when the hive is opened,
|
||||
// noticeable rain and strong wind both make bees more defensive and the
|
||||
// frames harder to handle safely.
|
||||
const INSPECTION_MIN_TEMP_C = 15;
|
||||
const INSPECTION_MAX_PRECIPITATION_MM = 1;
|
||||
const INSPECTION_MAX_WIND_KMH = 25;
|
||||
|
||||
export interface DailyWeather {
|
||||
date: string;
|
||||
tempMaxC: number | null;
|
||||
tempMinC: number | null;
|
||||
precipitationMm: number | null;
|
||||
windSpeedMaxKmh: number | null;
|
||||
// null when a required value is missing (rather than a misleading guess).
|
||||
inspectionSuitable: boolean | null;
|
||||
}
|
||||
|
||||
export interface ApiaryWeather {
|
||||
@@ -41,6 +53,7 @@ interface OpenMeteoDaily {
|
||||
temperature_2m_max: (number | null)[];
|
||||
temperature_2m_min: (number | null)[];
|
||||
precipitation_sum: (number | null)[];
|
||||
wind_speed_10m_max: (number | null)[];
|
||||
}
|
||||
|
||||
interface OpenMeteoResponse {
|
||||
@@ -95,8 +108,9 @@ export class WeatherService {
|
||||
url.searchParams.set('longitude', String(longitude));
|
||||
url.searchParams.set(
|
||||
'daily',
|
||||
'temperature_2m_max,temperature_2m_min,precipitation_sum',
|
||||
'temperature_2m_max,temperature_2m_min,precipitation_sum,wind_speed_10m_max',
|
||||
);
|
||||
url.searchParams.set('wind_speed_unit', 'kmh');
|
||||
url.searchParams.set('past_days', String(PAST_DAYS));
|
||||
url.searchParams.set('forecast_days', String(FORECAST_DAYS));
|
||||
url.searchParams.set('timezone', 'auto');
|
||||
@@ -116,14 +130,33 @@ export class WeatherService {
|
||||
if (!body.daily) {
|
||||
return [];
|
||||
}
|
||||
const { time, temperature_2m_max, temperature_2m_min, precipitation_sum } =
|
||||
body.daily;
|
||||
return time.map((date, index) => ({
|
||||
const {
|
||||
time,
|
||||
temperature_2m_max,
|
||||
temperature_2m_min,
|
||||
precipitation_sum,
|
||||
wind_speed_10m_max,
|
||||
} = body.daily;
|
||||
return time.map((date, index) => {
|
||||
const tempMaxC = temperature_2m_max[index] ?? null;
|
||||
const precipitationMm = precipitation_sum[index] ?? null;
|
||||
const windSpeedMaxKmh = wind_speed_10m_max[index] ?? null;
|
||||
return {
|
||||
date,
|
||||
tempMaxC: temperature_2m_max[index] ?? null,
|
||||
tempMaxC,
|
||||
tempMinC: temperature_2m_min[index] ?? null,
|
||||
precipitationMm: precipitation_sum[index] ?? null,
|
||||
}));
|
||||
precipitationMm,
|
||||
windSpeedMaxKmh,
|
||||
inspectionSuitable:
|
||||
tempMaxC === null ||
|
||||
precipitationMm === null ||
|
||||
windSpeedMaxKmh === null
|
||||
? null
|
||||
: tempMaxC >= INSPECTION_MIN_TEMP_C &&
|
||||
precipitationMm <= INSPECTION_MAX_PRECIPITATION_MM &&
|
||||
windSpeedMaxKmh <= INSPECTION_MAX_WIND_KMH,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private getSeason(month: number): { season: string; inBloom: boolean } {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const CACHE_NAME = "apicore-shell-v2";
|
||||
const CACHE_NAME = "apicore-shell-v3";
|
||||
|
||||
self.addEventListener("install", () => {
|
||||
self.skipWaiting();
|
||||
@@ -23,14 +23,33 @@ self.addEventListener("fetch", (event) => {
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (url.origin !== self.location.origin || url.pathname.startsWith("/api")) {
|
||||
|
||||
// /api/runtime-config tells the app which backend origin to use - it
|
||||
// must always be fetched live. A stale cached value would point the app
|
||||
// at the wrong (or no longer running) backend, which is worse than no
|
||||
// cache at all.
|
||||
if (url.pathname === "/api/runtime-config") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Same-origin static assets/pages, or the real backend API. The backend
|
||||
// runs on its own origin (API_URL, injected at runtime via
|
||||
// /api/runtime-config) rather than this app's own origin, so it can't be
|
||||
// matched by origin the way static assets are - matched by path instead,
|
||||
// since every backend call goes through /api/v1/. Without this, colonies,
|
||||
// apiaries, tasks etc. were never cached at all: a beekeeper opening the
|
||||
// app with no signal at an apiary saw nothing, not even data they had
|
||||
// already loaded minutes earlier on the same device.
|
||||
const isCacheable =
|
||||
url.origin === self.location.origin || url.pathname.includes("/api/v1/");
|
||||
if (!isCacheable) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Page navigations must never be answered with a synthetic Response.error() -
|
||||
// Chrome/Edge render that as a native "This page couldn't load" interstitial
|
||||
// instead of the app. Let the browser handle navigations directly; only
|
||||
// static assets (JS/CSS/images) go through the offline-cache fallback below.
|
||||
// static assets/API data go through the offline-cache fallback below.
|
||||
if (request.mode === "navigate") {
|
||||
return;
|
||||
}
|
||||
@@ -38,6 +57,12 @@ self.addEventListener("fetch", (event) => {
|
||||
event.respondWith(
|
||||
fetch(request)
|
||||
.then((response) => {
|
||||
// Never cache an error response (e.g. a 401 hit right as an access
|
||||
// token expired, before refresh could kick in) - it would otherwise
|
||||
// get served back later as if it were still the current state.
|
||||
if (!response.ok) {
|
||||
return response;
|
||||
}
|
||||
const responseClone = response.clone();
|
||||
return caches
|
||||
.open(CACHE_NAME)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { MapPin } from "lucide-react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { deactivateApiary, reactivateApiary, updateApiary } from "@/lib/apiaries/api";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import { Apiary } from "@/lib/apiaries/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
export function ApiaryEditCard({ apiary }: { apiary: Apiary }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [name, setName] = useState(apiary.name);
|
||||
const [description, setDescription] = useState(apiary.description ?? "");
|
||||
const [location, setLocation] = useState(apiary.location ?? "");
|
||||
const [latitude, setLatitude] = useState(apiary.latitude?.toString() ?? "");
|
||||
const [longitude, setLongitude] = useState(apiary.longitude?.toString() ?? "");
|
||||
@@ -32,6 +34,7 @@ export function ApiaryEditCard({ apiary }: { apiary: Apiary }) {
|
||||
mutationFn: () =>
|
||||
updateApiary(apiary.id, {
|
||||
name,
|
||||
description: description || undefined,
|
||||
location: location || undefined,
|
||||
latitude: latitude ? Number(latitude) : undefined,
|
||||
longitude: longitude ? Number(longitude) : undefined,
|
||||
@@ -41,6 +44,7 @@ export function ApiaryEditCard({ apiary }: { apiary: Apiary }) {
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["apiaries"] });
|
||||
},
|
||||
onError: () => toast.error("Bienenstand konnte nicht gespeichert werden"),
|
||||
});
|
||||
|
||||
const deactivateMutation = useMutation({
|
||||
@@ -51,7 +55,10 @@ export function ApiaryEditCard({ apiary }: { apiary: Apiary }) {
|
||||
description: "Er kann jederzeit über „Reaktivieren“ wiederhergestellt werden.",
|
||||
});
|
||||
},
|
||||
onError: () => toast.error("Bienenstand konnte nicht deaktiviert werden"),
|
||||
onError: (error) =>
|
||||
toast.error(
|
||||
error instanceof ApiError ? error.message : "Bienenstand konnte nicht deaktiviert werden",
|
||||
),
|
||||
});
|
||||
|
||||
const reactivateMutation = useMutation({
|
||||
@@ -95,6 +102,14 @@ export function ApiaryEditCard({ apiary }: { apiary: Apiary }) {
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="apiary-description">Beschreibung</Label>
|
||||
<Textarea
|
||||
id="apiary-description"
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="apiary-location">Standort</Label>
|
||||
<Input
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { fetchApiary } from "@/lib/apiaries/api";
|
||||
import { exportApiaryAnnualReportPdf } from "@/lib/export/api";
|
||||
import { QrCodeDialog } from "@/components/qr-code-dialog";
|
||||
@@ -22,6 +23,7 @@ export default function ApiaryDetailPage() {
|
||||
|
||||
const exportReportMutation = useMutation({
|
||||
mutationFn: () => exportApiaryAnnualReportPdf(params.id, CURRENT_YEAR),
|
||||
onError: () => toast.error("Jahresbericht konnte nicht exportiert werden"),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { CloudSun } from "lucide-react";
|
||||
import { CloudSun, Wind, CheckCircle2, XCircle } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchApiaryWeather, fetchTrachtprognose } from "@/lib/weather/api";
|
||||
import { TrachtprognoseRating } from "@/lib/weather/types";
|
||||
@@ -73,6 +73,7 @@ export function WeatherCard({
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const forecastDays = (weather?.daily ?? []).filter((day) => day.date >= today).slice(0, 7);
|
||||
const todaySuitability = forecastDays[0]?.inspectionSuitable ?? null;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -104,6 +105,27 @@ export function WeatherCard({
|
||||
{weatherLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Wetterdaten laden…</p>
|
||||
) : forecastDays.length > 0 ? (
|
||||
<>
|
||||
{todaySuitability !== null ? (
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-lg border p-2.5 text-sm ${
|
||||
todaySuitability
|
||||
? "border-primary/30 bg-primary/5 text-primary"
|
||||
: "border-warning/40 bg-warning/10 text-warning-foreground"
|
||||
}`}
|
||||
>
|
||||
{todaySuitability ? (
|
||||
<CheckCircle2 className="size-4.5 shrink-0" />
|
||||
) : (
|
||||
<XCircle className="size-4.5 shrink-0" />
|
||||
)}
|
||||
<span>
|
||||
{todaySuitability
|
||||
? "Heute gut für eine Durchsicht geeignet."
|
||||
: "Heute eher ungeeignet für eine Durchsicht (zu kalt, nass oder windig)."}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="grid grid-cols-3 gap-2 border-t border-border/50 pt-3 sm:grid-cols-7">
|
||||
{forecastDays.map((day) => (
|
||||
<div key={day.date} className="flex flex-col items-center gap-0.5 text-center">
|
||||
@@ -121,9 +143,16 @@ export function WeatherCard({
|
||||
{day.precipitationMm.toFixed(0)} mm
|
||||
</span>
|
||||
) : null}
|
||||
{day.windSpeedMaxKmh !== null ? (
|
||||
<span className="flex items-center gap-0.5 text-xs text-muted-foreground">
|
||||
<Wind className="size-3" />
|
||||
{Math.round(day.windSpeedMaxKmh)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { deleteBottlingBatch } from "@/lib/bottling/api";
|
||||
import { BottlingBatch } from "@/lib/bottling/types";
|
||||
import { ConfirmDialog } from "@/components/confirm-dialog";
|
||||
|
||||
/**
|
||||
* Deleting a bottling batch is the correction path for a mis-entered record:
|
||||
* the bottled amount goes back to the honey batch and the full row is kept in
|
||||
* the audit log. It also retires the lot number, so the public origin page
|
||||
* /herkunft/<lotNumber> stops resolving - hence the explicit confirmation.
|
||||
*/
|
||||
export function BottlingDeleteButton({
|
||||
bottlingBatch,
|
||||
}: {
|
||||
bottlingBatch: BottlingBatch;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => deleteBottlingBatch(bottlingBatch.id),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["bottling-batches"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["honey-batches"] });
|
||||
toast.success("Abfüllung gelöscht");
|
||||
router.push("/bottling");
|
||||
},
|
||||
onError: (error: Error) =>
|
||||
toast.error(error.message || "Abfüllung konnte nicht gelöscht werden"),
|
||||
});
|
||||
|
||||
return (
|
||||
<ConfirmDialog
|
||||
triggerLabel="Löschen"
|
||||
triggerVariant="outline"
|
||||
triggerSize="sm"
|
||||
title="Abfüllung löschen"
|
||||
description={`Abfüllung ${bottlingBatch.lotNumber} wird entfernt und ${bottlingBatch.totalAmount} kg werden der Honigcharge ${bottlingBatch.honeyBatch.batchNumber} wieder gutgeschrieben. Die öffentliche Herkunftsseite zu dieser Los-/Chargennummer ist danach nicht mehr erreichbar.`}
|
||||
confirmLabel="Endgültig löschen"
|
||||
onConfirm={() => mutation.mutate()}
|
||||
isPending={mutation.isPending}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2,12 +2,22 @@
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { updateBottlingBatch } from "@/lib/bottling/api";
|
||||
import { BottlingBatch } from "@/lib/bottling/types";
|
||||
import { fetchHoneyBatches } from "@/lib/honey-batches/api";
|
||||
import { fetchProducts } from "@/lib/products/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -16,34 +26,19 @@ import {
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
export function BottlingEditDialog({ bottlingBatch }: { bottlingBatch: BottlingBatch }) {
|
||||
const queryClient = useQueryClient();
|
||||
/**
|
||||
* Sentinel for "no product": the select needs a selectable item to clear the
|
||||
* assignment again, and an empty string would be read as "nothing chosen yet"
|
||||
* and render the placeholder instead.
|
||||
*/
|
||||
const NO_PRODUCT = "__none__";
|
||||
|
||||
export function BottlingEditDialog({
|
||||
bottlingBatch,
|
||||
}: {
|
||||
bottlingBatch: BottlingBatch;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [date, setDate] = useState(bottlingBatch.date.slice(0, 10));
|
||||
const [bestBeforeDate, setBestBeforeDate] = useState(
|
||||
bottlingBatch.bestBeforeDate.slice(0, 10),
|
||||
);
|
||||
const [lotNumber, setLotNumber] = useState(bottlingBatch.lotNumber);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
updateBottlingBatch(bottlingBatch.id, {
|
||||
date: new Date(date).toISOString(),
|
||||
bestBeforeDate: new Date(bestBeforeDate).toISOString(),
|
||||
lotNumber,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["bottling-batches", bottlingBatch.id],
|
||||
});
|
||||
setOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
mutation.mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
@@ -59,25 +54,188 @@ export function BottlingEditDialog({ bottlingBatch }: { bottlingBatch: BottlingB
|
||||
<DialogHeader>
|
||||
<DialogTitle>Abfüllung bearbeiten</DialogTitle>
|
||||
</DialogHeader>
|
||||
{/* Mounted only while the dialog is open, so every reopen starts from
|
||||
the saved values instead of what a cancelled edit left behind. */}
|
||||
{open ? (
|
||||
<BottlingEditForm
|
||||
bottlingBatch={bottlingBatch}
|
||||
onSaved={() => setOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function BottlingEditForm({
|
||||
bottlingBatch,
|
||||
onSaved,
|
||||
}: {
|
||||
bottlingBatch: BottlingBatch;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [honeyBatchId, setHoneyBatchId] = useState(bottlingBatch.honeyBatchId);
|
||||
const [date, setDate] = useState(bottlingBatch.date.slice(0, 10));
|
||||
const [bestBeforeDate, setBestBeforeDate] = useState(
|
||||
bottlingBatch.bestBeforeDate.slice(0, 10),
|
||||
);
|
||||
const [jarSize, setJarSize] = useState(String(bottlingBatch.jarSize));
|
||||
const [jarCount, setJarCount] = useState(String(bottlingBatch.jarCount));
|
||||
const [lotNumber, setLotNumber] = useState(bottlingBatch.lotNumber);
|
||||
const [productId, setProductId] = useState(
|
||||
bottlingBatch.productId ?? NO_PRODUCT,
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { data: honeyBatches } = useQuery({
|
||||
queryKey: ["honey-batches"],
|
||||
queryFn: () => fetchHoneyBatches(),
|
||||
});
|
||||
const { data: products } = useQuery({
|
||||
queryKey: ["products", { status: "ACTIVE" }],
|
||||
queryFn: () => fetchProducts({ status: "ACTIVE" }),
|
||||
});
|
||||
|
||||
// The assigned product may have been discontinued since bottling - keep it
|
||||
// selectable so opening the dialog does not silently drop the assignment.
|
||||
const productOptions = [
|
||||
...(products ?? []).map((product) => ({
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
})),
|
||||
...(bottlingBatch.product &&
|
||||
!(products ?? []).some(
|
||||
(product) => product.id === bottlingBatch.product?.id,
|
||||
)
|
||||
? [bottlingBatch.product]
|
||||
: []),
|
||||
];
|
||||
const honeyBatchOptions = [
|
||||
...(honeyBatches ?? []).map((honeyBatch) => ({
|
||||
id: honeyBatch.id,
|
||||
batchNumber: honeyBatch.batchNumber,
|
||||
})),
|
||||
...((honeyBatches ?? []).some(
|
||||
(honeyBatch) => honeyBatch.id === bottlingBatch.honeyBatchId,
|
||||
)
|
||||
? []
|
||||
: [bottlingBatch.honeyBatch]),
|
||||
];
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
updateBottlingBatch(bottlingBatch.id, {
|
||||
honeyBatchId,
|
||||
date: new Date(date).toISOString(),
|
||||
bestBeforeDate: new Date(bestBeforeDate).toISOString(),
|
||||
jarSize: Number(jarSize),
|
||||
jarCount: Number(jarCount),
|
||||
lotNumber,
|
||||
productId: productId === NO_PRODUCT ? null : productId,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["bottling-batches"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["honey-batches"] });
|
||||
onSaved();
|
||||
},
|
||||
onError: (mutationError: Error) => {
|
||||
const message =
|
||||
mutationError.message || "Abfüllung konnte nicht gespeichert werden";
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
mutation.mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="bottling-edit-honey-batch">Honigcharge</Label>
|
||||
<Select
|
||||
value={honeyBatchId}
|
||||
onValueChange={(value) => setHoneyBatchId(value ?? honeyBatchId)}
|
||||
>
|
||||
<SelectTrigger id="bottling-edit-honey-batch">
|
||||
<SelectValue placeholder="Charge wählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{honeyBatchOptions.map((honeyBatch) => (
|
||||
<SelectItem key={honeyBatch.id} value={honeyBatch.id}>
|
||||
{honeyBatch.batchNumber}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="bottling-edit-date">Datum</Label>
|
||||
<Input
|
||||
id="bottling-edit-date"
|
||||
type="date"
|
||||
required
|
||||
value={date}
|
||||
onChange={(event) => setDate(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="bottling-edit-best-before">Mindesthaltbarkeitsdatum</Label>
|
||||
<Label htmlFor="bottling-edit-best-before">
|
||||
Mindesthaltbarkeitsdatum
|
||||
</Label>
|
||||
<Input
|
||||
id="bottling-edit-best-before"
|
||||
type="date"
|
||||
required
|
||||
value={bestBeforeDate}
|
||||
onChange={(event) => setBestBeforeDate(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="bottling-edit-jar-size">Glasgröße (g)</Label>
|
||||
<Input
|
||||
id="bottling-edit-jar-size"
|
||||
type="number"
|
||||
min="1"
|
||||
required
|
||||
value={jarSize}
|
||||
onChange={(event) => setJarSize(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="bottling-edit-jar-count">Anzahl Gläser</Label>
|
||||
<Input
|
||||
id="bottling-edit-jar-count"
|
||||
type="number"
|
||||
min="1"
|
||||
required
|
||||
value={jarCount}
|
||||
onChange={(event) => setJarCount(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="bottling-edit-product">Produkt</Label>
|
||||
<Select
|
||||
value={productId}
|
||||
onValueChange={(value) => setProductId(value ?? NO_PRODUCT)}
|
||||
>
|
||||
<SelectTrigger id="bottling-edit-product">
|
||||
<SelectValue placeholder="Produkt wählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_PRODUCT}>Kein Produkt</SelectItem>
|
||||
{productOptions.map((product) => (
|
||||
<SelectItem key={product.id} value={product.id}>
|
||||
{product.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="bottling-edit-lot">Los-/Chargennummer</Label>
|
||||
<Input
|
||||
@@ -87,11 +245,14 @@ export function BottlingEditDialog({ bottlingBatch }: { bottlingBatch: BottlingB
|
||||
onChange={(event) => setLotNumber(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Speichert…" : "Speichern"}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -7,11 +7,14 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchBottlingBatch } from "@/lib/bottling/api";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { QrCodeDialog } from "@/components/qr-code-dialog";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { BottlingEditDialog } from "./bottling-edit-dialog";
|
||||
import { BottlingDeleteButton } from "./bottling-delete-button";
|
||||
import { PrintLabelsCard } from "./print-labels-card";
|
||||
|
||||
export default function BottlingBatchDetailPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const { hasPermission } = useAuth();
|
||||
const { data: bottlingBatch, isLoading, isError } = useQuery({
|
||||
queryKey: ["bottling-batches", params.id],
|
||||
queryFn: () => fetchBottlingBatch(params.id),
|
||||
@@ -43,7 +46,12 @@ export default function BottlingBatchDetailPage() {
|
||||
<CardTitle className="font-mono">Abfüllung {bottlingBatch.lotNumber}</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasPermission("bottling:update") ? (
|
||||
<BottlingEditDialog bottlingBatch={bottlingBatch} />
|
||||
) : null}
|
||||
{hasPermission("bottling:delete") ? (
|
||||
<BottlingDeleteButton bottlingBatch={bottlingBatch} />
|
||||
) : null}
|
||||
<QrCodeDialog
|
||||
entityType="bottling-batches"
|
||||
id={bottlingBatch.id}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import {
|
||||
dissolveColony,
|
||||
mergeColony,
|
||||
@@ -35,6 +36,10 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
function errorMessage(error: unknown, fallback: string): string {
|
||||
return error instanceof ApiError ? error.message : fallback;
|
||||
}
|
||||
|
||||
function invalidateColony(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
colonyId: string,
|
||||
@@ -48,7 +53,10 @@ function MoveDialog({ colony }: { colony: Colony }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [apiaryId, setApiaryId] = useState("");
|
||||
const { data: apiaries } = useQuery({ queryKey: ["apiaries"], queryFn: () => fetchApiaries() });
|
||||
const { data: apiaries } = useQuery({
|
||||
queryKey: ["apiaries", { status: "ACTIVE" }],
|
||||
queryFn: () => fetchApiaries({ status: "ACTIVE" }),
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => moveColony(colony.id, { apiaryId }),
|
||||
@@ -56,6 +64,8 @@ function MoveDialog({ colony }: { colony: Colony }) {
|
||||
invalidateColony(queryClient, colony.id);
|
||||
setOpen(false);
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(errorMessage(error, "Volk konnte nicht umgesetzt werden")),
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -119,6 +129,8 @@ function SplitDialog({ colony }: { colony: Colony }) {
|
||||
void queryClient.invalidateQueries({ queryKey: ["colonies"] });
|
||||
setOpen(false);
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(errorMessage(error, "Ableger konnte nicht angelegt werden")),
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -193,6 +205,8 @@ function AssignQueenDialog({ colony }: { colony: Colony }) {
|
||||
void queryClient.invalidateQueries({ queryKey: ["queens"] });
|
||||
setOpen(false);
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(errorMessage(error, "Königin konnte nicht zugewiesen werden")),
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -256,6 +270,8 @@ function MergeDialog({ colony }: { colony: Colony }) {
|
||||
void queryClient.invalidateQueries({ queryKey: ["colonies"] });
|
||||
setOpen(false);
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(errorMessage(error, "Völker konnten nicht vereinigt werden")),
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { Layers } from "lucide-react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { updateColony } from "@/lib/colonies/api";
|
||||
import { Colony, ColonyCondition, ColonyType } from "@/lib/colonies/types";
|
||||
import { fetchApiaries } from "@/lib/apiaries/api";
|
||||
import { Colony, ColonyCondition, ColonyStatus, ColonyType } from "@/lib/colonies/types";
|
||||
import {
|
||||
COLONY_CONDITIONS,
|
||||
COLONY_CONDITION_LABELS,
|
||||
COLONY_CONDITION_TONE_CLASS,
|
||||
COLONY_EDITABLE_STATUSES,
|
||||
COLONY_STATUS_LABELS,
|
||||
COLONY_STRENGTH_OPTIONS,
|
||||
COLONY_TYPES,
|
||||
@@ -74,6 +77,9 @@ function ConditionSelect({
|
||||
|
||||
export function ColonyEditCard({ colony }: { colony: Colony }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [apiaryId, setApiaryId] = useState(colony.apiaryId);
|
||||
const [code, setCode] = useState(colony.code);
|
||||
const [status, setStatus] = useState<ColonyStatus>(colony.status);
|
||||
const [colonyType, setColonyType] = useState<ColonyType>(colony.colonyType);
|
||||
const [hiveType, setHiveType] = useState(colony.hiveType ?? "");
|
||||
const [originText, setOriginText] = useState(colony.originText ?? "");
|
||||
@@ -89,9 +95,17 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
|
||||
);
|
||||
const [notes, setNotes] = useState(colony.notes ?? "");
|
||||
|
||||
const { data: apiaries } = useQuery({
|
||||
queryKey: ["apiaries"],
|
||||
queryFn: () => fetchApiaries(),
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
updateColony(colony.id, {
|
||||
apiaryId,
|
||||
code,
|
||||
status,
|
||||
colonyType,
|
||||
hiveType: hiveType || undefined,
|
||||
originText: originText || undefined,
|
||||
@@ -104,7 +118,10 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["colonies"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["colonies", colony.id] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["apiaries"] });
|
||||
},
|
||||
onError: (error: Error) =>
|
||||
toast.error(error.message || "Volk konnte nicht gespeichert werden"),
|
||||
});
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
@@ -128,6 +145,52 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="colony-apiary">Bienenstand</Label>
|
||||
<Select
|
||||
value={apiaryId}
|
||||
onValueChange={(value) => setApiaryId(value ?? apiaryId)}
|
||||
>
|
||||
<SelectTrigger id="colony-apiary">
|
||||
<SelectValue placeholder="Bienenstand wählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(apiaries ?? []).map((apiary) => (
|
||||
<SelectItem key={apiary.id} value={apiary.id}>
|
||||
{apiary.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Eine Korrektur wird wie ein Umsetzen in der Standort-Historie
|
||||
festgehalten.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="colony-code">Volksnummer</Label>
|
||||
<Input
|
||||
id="colony-code"
|
||||
required
|
||||
value={code}
|
||||
onChange={(event) => setCode(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="colony-status">Status</Label>
|
||||
<Select value={status} onValueChange={(value) => setStatus(value as ColonyStatus)}>
|
||||
<SelectTrigger id="colony-status">
|
||||
<SelectValue>{COLONY_STATUS_LABELS[status]}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{COLONY_EDITABLE_STATUSES.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{COLONY_STATUS_LABELS[option]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="colony-type">Volktyp</Label>
|
||||
<Select
|
||||
|
||||
@@ -49,6 +49,9 @@ export function ColonyTreatmentsCard({ colonyId }: { colonyId: string }) {
|
||||
{treatment.method ? ` · ${treatment.method}` : ""}
|
||||
</p>
|
||||
{treatment.reason ? <p>Grund: {treatment.reason}</p> : null}
|
||||
{treatment.withdrawalPeriodDays !== null ? (
|
||||
<p>Wartezeit: {treatment.withdrawalPeriodDays} Tag(e)</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { updateFeeding } from "@/lib/feedings/api";
|
||||
import { Feeding, FeedType } from "@/lib/feedings/types";
|
||||
import { FEED_TYPES, FEED_TYPE_LABELS } from "@/lib/feedings/labels";
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ColonySelectField } from "@/components/colony-select-field";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -28,29 +30,38 @@ import {
|
||||
export function FeedingEditDialog({ feeding }: { feeding: Feeding }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [colonyId, setColonyId] = useState(feeding.colonyId);
|
||||
const [date, setDate] = useState(feeding.date.slice(0, 10));
|
||||
const [feedType, setFeedType] = useState<FeedType>(feeding.feedType);
|
||||
const [product, setProduct] = useState(feeding.product ?? "");
|
||||
const [amount, setAmount] = useState(feeding.amount.toString());
|
||||
const [unit, setUnit] = useState(feeding.unit);
|
||||
const [batchNumber, setBatchNumber] = useState(feeding.batchNumber ?? "");
|
||||
const [notes, setNotes] = useState(feeding.notes ?? "");
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
updateFeeding(feeding.id, {
|
||||
colonyId,
|
||||
date: new Date(date).toISOString(),
|
||||
feedType,
|
||||
product: product || undefined,
|
||||
amount: amount ? Number(amount) : undefined,
|
||||
unit,
|
||||
batchNumber: batchNumber || undefined,
|
||||
notes: notes || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
// After re-filing, both the colony it left and the one it joined
|
||||
// show a stale list until each is invalidated.
|
||||
for (const affectedColonyId of new Set([feeding.colonyId, colonyId])) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["colonies", feeding.colonyId, "feedings"],
|
||||
queryKey: ["colonies", affectedColonyId, "feedings"],
|
||||
});
|
||||
}
|
||||
setOpen(false);
|
||||
},
|
||||
onError: () => toast.error("Fütterung konnte nicht gespeichert werden"),
|
||||
});
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
@@ -73,6 +84,11 @@ export function FeedingEditDialog({ feeding }: { feeding: Feeding }) {
|
||||
<DialogTitle>Fütterung bearbeiten</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<ColonySelectField
|
||||
id="feeding-edit-colony"
|
||||
value={colonyId}
|
||||
onChange={setColonyId}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="feeding-edit-date">Datum</Label>
|
||||
<Input
|
||||
@@ -106,6 +122,7 @@ export function FeedingEditDialog({ feeding }: { feeding: Feeding }) {
|
||||
<Input
|
||||
id="feeding-edit-amount"
|
||||
type="number"
|
||||
min={0}
|
||||
value={amount}
|
||||
onChange={(event) => setAmount(event.target.value)}
|
||||
/>
|
||||
@@ -127,6 +144,14 @@ export function FeedingEditDialog({ feeding }: { feeding: Feeding }) {
|
||||
onChange={(event) => setProduct(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="feeding-edit-batch">Chargennummer</Label>
|
||||
<Input
|
||||
id="feeding-edit-batch"
|
||||
value={batchNumber}
|
||||
onChange={(event) => setBatchNumber(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="feeding-edit-notes">Notizen</Label>
|
||||
<Textarea
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { updateInspection } from "@/lib/inspections/api";
|
||||
import { Inspection } from "@/lib/inspections/types";
|
||||
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ColonySelectField } from "@/components/colony-select-field";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -33,13 +35,29 @@ import {
|
||||
export function InspectionEditDialog({ inspection }: { inspection: Inspection }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [colonyId, setColonyId] = useState(inspection.colonyId);
|
||||
const [date, setDate] = useState(inspection.date.slice(0, 10));
|
||||
const [weather, setWeather] = useState(inspection.weather ?? "");
|
||||
const [temperature, setTemperature] = useState(inspection.temperature?.toString() ?? "");
|
||||
const [colonyStrength, setColonyStrength] = useState(
|
||||
inspection.colonyStrength?.toString() ?? "",
|
||||
);
|
||||
const [broodAmount, setBroodAmount] = useState(inspection.broodAmount ?? "");
|
||||
const [broodPattern, setBroodPattern] = useState(inspection.broodPattern?.toString() ?? "");
|
||||
const [openBroodPresent, setOpenBroodPresent] = useState(inspection.openBroodPresent ?? false);
|
||||
const [cappedBroodPresent, setCappedBroodPresent] = useState(
|
||||
inspection.cappedBroodPresent ?? false,
|
||||
);
|
||||
const [queenSeen, setQueenSeen] = useState(inspection.queenSeen ?? false);
|
||||
const [eggsPresent, setEggsPresent] = useState(inspection.eggsPresent ?? true);
|
||||
const [queenCellsPresent, setQueenCellsPresent] = useState(
|
||||
inspection.queenCellsPresent ?? false,
|
||||
);
|
||||
const [swarmMood, setSwarmMood] = useState(inspection.swarmMood?.toString() ?? "");
|
||||
const [gentleness, setGentleness] = useState(inspection.gentleness?.toString() ?? "");
|
||||
const [combBehavior, setCombBehavior] = useState(inspection.combBehavior?.toString() ?? "");
|
||||
const [feedStatus, setFeedStatus] = useState(inspection.feedStatus ?? "ausreichend");
|
||||
const [honeySuperStatus, setHoneySuperStatus] = useState(inspection.honeySuperStatus ?? "");
|
||||
const [healthIssues, setHealthIssues] = useState(inspection.healthIssues ?? "");
|
||||
const [varroaIssues, setVarroaIssues] = useState(inspection.varroaIssues ?? "");
|
||||
const [measures, setMeasures] = useState(inspection.measures ?? "");
|
||||
@@ -48,22 +66,39 @@ export function InspectionEditDialog({ inspection }: { inspection: Inspection })
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
updateInspection(inspection.id, {
|
||||
colonyId,
|
||||
date: new Date(date).toISOString(),
|
||||
weather: weather || undefined,
|
||||
temperature: temperature ? Number(temperature) : undefined,
|
||||
colonyStrength: colonyStrength ? Number(colonyStrength) : undefined,
|
||||
broodAmount: broodAmount || undefined,
|
||||
broodPattern: broodPattern ? Number(broodPattern) : undefined,
|
||||
openBroodPresent,
|
||||
cappedBroodPresent,
|
||||
queenSeen,
|
||||
eggsPresent,
|
||||
queenCellsPresent,
|
||||
swarmMood: swarmMood ? Number(swarmMood) : undefined,
|
||||
gentleness: gentleness ? Number(gentleness) : undefined,
|
||||
combBehavior: combBehavior ? Number(combBehavior) : undefined,
|
||||
feedStatus,
|
||||
honeySuperStatus: honeySuperStatus || undefined,
|
||||
healthIssues: healthIssues || undefined,
|
||||
varroaIssues: varroaIssues || undefined,
|
||||
measures: measures || undefined,
|
||||
notes: notes || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
// After re-filing, both the colony it left and the one it joined
|
||||
// show a stale list until each is invalidated.
|
||||
for (const affectedColonyId of new Set([inspection.colonyId, colonyId])) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["colonies", inspection.colonyId, "inspections"],
|
||||
queryKey: ["colonies", affectedColonyId, "inspections"],
|
||||
});
|
||||
}
|
||||
setOpen(false);
|
||||
},
|
||||
onError: () => toast.error("Durchsicht konnte nicht gespeichert werden"),
|
||||
});
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
@@ -86,6 +121,11 @@ export function InspectionEditDialog({ inspection }: { inspection: Inspection })
|
||||
<DialogTitle>Durchsicht bearbeiten</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<ColonySelectField
|
||||
id="inspection-edit-colony"
|
||||
value={colonyId}
|
||||
onChange={setColonyId}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="inspection-edit-date">Datum</Label>
|
||||
<Input
|
||||
@@ -95,6 +135,24 @@ export function InspectionEditDialog({ inspection }: { inspection: Inspection })
|
||||
onChange={(event) => setDate(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="inspection-edit-weather">Wetter</Label>
|
||||
<Input
|
||||
id="inspection-edit-weather"
|
||||
value={weather}
|
||||
onChange={(event) => setWeather(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="inspection-edit-temperature">Temperatur (°C)</Label>
|
||||
<Input
|
||||
id="inspection-edit-temperature"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
value={temperature}
|
||||
onChange={(event) => setTemperature(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="inspection-edit-strength">Volksstärke (Waben)</Label>
|
||||
<Input
|
||||
@@ -104,6 +162,48 @@ export function InspectionEditDialog({ inspection }: { inspection: Inspection })
|
||||
onChange={(event) => setColonyStrength(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="inspection-edit-brood-amount">Brutmenge</Label>
|
||||
<Input
|
||||
id="inspection-edit-brood-amount"
|
||||
value={broodAmount}
|
||||
onChange={(event) => setBroodAmount(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="inspection-edit-brood-pattern">Brutbild (1–5)</Label>
|
||||
<Input
|
||||
id="inspection-edit-brood-pattern"
|
||||
type="number"
|
||||
min={1}
|
||||
max={5}
|
||||
value={broodPattern}
|
||||
onChange={(event) => setBroodPattern(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-3 text-sm" htmlFor="inspection-edit-open-brood">
|
||||
<input
|
||||
id="inspection-edit-open-brood"
|
||||
type="checkbox"
|
||||
className="size-5"
|
||||
checked={openBroodPresent}
|
||||
onChange={(event) => setOpenBroodPresent(event.target.checked)}
|
||||
/>
|
||||
Offene Brut vorhanden
|
||||
</label>
|
||||
<label
|
||||
className="flex items-center gap-3 text-sm"
|
||||
htmlFor="inspection-edit-capped-brood"
|
||||
>
|
||||
<input
|
||||
id="inspection-edit-capped-brood"
|
||||
type="checkbox"
|
||||
className="size-5"
|
||||
checked={cappedBroodPresent}
|
||||
onChange={(event) => setCappedBroodPresent(event.target.checked)}
|
||||
/>
|
||||
Verdeckelte Brut vorhanden
|
||||
</label>
|
||||
<label className="flex items-center gap-3 text-sm" htmlFor="inspection-edit-queen">
|
||||
<input
|
||||
id="inspection-edit-queen"
|
||||
@@ -124,6 +224,52 @@ export function InspectionEditDialog({ inspection }: { inspection: Inspection })
|
||||
/>
|
||||
Stifte vorhanden
|
||||
</label>
|
||||
<label
|
||||
className="flex items-center gap-3 text-sm"
|
||||
htmlFor="inspection-edit-queen-cells"
|
||||
>
|
||||
<input
|
||||
id="inspection-edit-queen-cells"
|
||||
type="checkbox"
|
||||
className="size-5"
|
||||
checked={queenCellsPresent}
|
||||
onChange={(event) => setQueenCellsPresent(event.target.checked)}
|
||||
/>
|
||||
Weiselzellen vorhanden
|
||||
</label>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="inspection-edit-swarm-mood">Schwarmstimmung (1–5)</Label>
|
||||
<Input
|
||||
id="inspection-edit-swarm-mood"
|
||||
type="number"
|
||||
min={1}
|
||||
max={5}
|
||||
value={swarmMood}
|
||||
onChange={(event) => setSwarmMood(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="inspection-edit-gentleness">Sanftmut (1–5)</Label>
|
||||
<Input
|
||||
id="inspection-edit-gentleness"
|
||||
type="number"
|
||||
min={1}
|
||||
max={5}
|
||||
value={gentleness}
|
||||
onChange={(event) => setGentleness(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="inspection-edit-comb-behavior">Wabensitz (1–5)</Label>
|
||||
<Input
|
||||
id="inspection-edit-comb-behavior"
|
||||
type="number"
|
||||
min={1}
|
||||
max={5}
|
||||
value={combBehavior}
|
||||
onChange={(event) => setCombBehavior(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="inspection-edit-feed">Futterstatus</Label>
|
||||
<Select
|
||||
@@ -140,6 +286,14 @@ export function InspectionEditDialog({ inspection }: { inspection: Inspection })
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="inspection-edit-honey-super">Honigraumstatus</Label>
|
||||
<Input
|
||||
id="inspection-edit-honey-super"
|
||||
value={honeySuperStatus}
|
||||
onChange={(event) => setHoneySuperStatus(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="inspection-edit-varroa">Varroa-Befund</Label>
|
||||
<Input
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { fetchColony } from "@/lib/colonies/api";
|
||||
import { exportColonyAnnualReportPdf } from "@/lib/export/api";
|
||||
import { QrCodeDialog } from "@/components/qr-code-dialog";
|
||||
@@ -27,6 +28,7 @@ export default function ColonyDetailPage() {
|
||||
|
||||
const exportReportMutation = useMutation({
|
||||
mutationFn: () => exportColonyAnnualReportPdf(params.id, CURRENT_YEAR),
|
||||
onError: () => toast.error("Jahresbericht konnte nicht exportiert werden"),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
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";
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ColonySelectField } from "@/components/colony-select-field";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -28,6 +30,7 @@ import {
|
||||
export function SwarmControlEditDialog({ check }: { check: SwarmControlCheck }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [colonyId, setColonyId] = useState(check.colonyId);
|
||||
const [date, setDate] = useState(check.date.slice(0, 10));
|
||||
const [queenCellsFound, setQueenCellsFound] = useState(check.queenCellsFound?.toString() ?? "");
|
||||
const [queenCellsCapped, setQueenCellsCapped] = useState(check.queenCellsCapped);
|
||||
@@ -39,6 +42,7 @@ export function SwarmControlEditDialog({ check }: { check: SwarmControlCheck })
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
updateSwarmControlCheck(check.id, {
|
||||
colonyId,
|
||||
date: new Date(date).toISOString(),
|
||||
queenCellsFound: queenCellsFound ? Number(queenCellsFound) : undefined,
|
||||
queenCellsCapped,
|
||||
@@ -48,11 +52,16 @@ export function SwarmControlEditDialog({ check }: { check: SwarmControlCheck })
|
||||
notes: notes || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
// After re-filing, both the colony it left and the one it joined
|
||||
// show a stale list until each is invalidated.
|
||||
for (const affectedColonyId of new Set([check.colonyId, colonyId])) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["colonies", check.colonyId, "swarm-control-checks"],
|
||||
queryKey: ["colonies", affectedColonyId, "swarm-control-checks"],
|
||||
});
|
||||
}
|
||||
setOpen(false);
|
||||
},
|
||||
onError: () => toast.error("Schwarmkontrolle konnte nicht gespeichert werden"),
|
||||
});
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
@@ -75,6 +84,11 @@ export function SwarmControlEditDialog({ check }: { check: SwarmControlCheck })
|
||||
<DialogTitle>Schwarmkontrolle bearbeiten</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<ColonySelectField
|
||||
id="swarm-edit-colony"
|
||||
value={colonyId}
|
||||
onChange={setColonyId}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="swarm-edit-date">Datum</Label>
|
||||
<Input
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { updateTreatment } from "@/lib/treatments/api";
|
||||
import { Treatment } from "@/lib/treatments/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 { ColonySelectField } from "@/components/colony-select-field";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -20,29 +22,48 @@ import {
|
||||
export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [colonyId, setColonyId] = useState(treatment.colonyId);
|
||||
const [date, setDate] = useState(treatment.date.slice(0, 10));
|
||||
const [startDate, setStartDate] = useState(treatment.startDate?.slice(0, 10) ?? "");
|
||||
const [endDate, setEndDate] = useState(treatment.endDate?.slice(0, 10) ?? "");
|
||||
const [product, setProduct] = useState(treatment.product);
|
||||
const [activeIngredient, setActiveIngredient] = useState(treatment.activeIngredient ?? "");
|
||||
const [amount, setAmount] = useState(treatment.amount ?? "");
|
||||
const [method, setMethod] = useState(treatment.method ?? "");
|
||||
const [reason, setReason] = useState(treatment.reason ?? "");
|
||||
const [batchNumber, setBatchNumber] = useState(treatment.batchNumber ?? "");
|
||||
const [notes, setNotes] = useState(treatment.notes ?? "");
|
||||
const [withdrawalPeriodDays, setWithdrawalPeriodDays] = useState(
|
||||
treatment.withdrawalPeriodDays?.toString() ?? "",
|
||||
);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
updateTreatment(treatment.id, {
|
||||
colonyId,
|
||||
date: new Date(date).toISOString(),
|
||||
startDate: startDate ? new Date(startDate).toISOString() : undefined,
|
||||
endDate: endDate ? new Date(endDate).toISOString() : undefined,
|
||||
product,
|
||||
activeIngredient: activeIngredient || undefined,
|
||||
amount: amount || undefined,
|
||||
method: method || undefined,
|
||||
reason: reason || undefined,
|
||||
batchNumber: batchNumber || undefined,
|
||||
notes: notes || undefined,
|
||||
withdrawalPeriodDays: withdrawalPeriodDays ? Number(withdrawalPeriodDays) : undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
// After re-filing, both the colony it left and the one it joined
|
||||
// show a stale list until each is invalidated.
|
||||
for (const affectedColonyId of new Set([treatment.colonyId, colonyId])) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["colonies", treatment.colonyId, "treatments"],
|
||||
queryKey: ["colonies", affectedColonyId, "treatments"],
|
||||
});
|
||||
}
|
||||
setOpen(false);
|
||||
},
|
||||
onError: () => toast.error("Behandlung konnte nicht gespeichert werden"),
|
||||
});
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
@@ -65,6 +86,11 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
|
||||
<DialogTitle>Behandlung bearbeiten</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<ColonySelectField
|
||||
id="treatment-edit-colony"
|
||||
value={colonyId}
|
||||
onChange={setColonyId}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="treatment-edit-date">Datum</Label>
|
||||
<Input
|
||||
@@ -74,6 +100,24 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
|
||||
onChange={(event) => setDate(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="treatment-edit-start-date">Beginn</Label>
|
||||
<Input
|
||||
id="treatment-edit-start-date"
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(event) => setStartDate(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="treatment-edit-end-date">Ende</Label>
|
||||
<Input
|
||||
id="treatment-edit-end-date"
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(event) => setEndDate(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="treatment-edit-product">Mittel</Label>
|
||||
<Input
|
||||
@@ -83,6 +127,14 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
|
||||
onChange={(event) => setProduct(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="treatment-edit-active-ingredient">Wirkstoff</Label>
|
||||
<Input
|
||||
id="treatment-edit-active-ingredient"
|
||||
value={activeIngredient}
|
||||
onChange={(event) => setActiveIngredient(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="treatment-edit-amount">Menge</Label>
|
||||
<Input
|
||||
@@ -107,6 +159,25 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="treatment-edit-batch">Chargennummer</Label>
|
||||
<Input
|
||||
id="treatment-edit-batch"
|
||||
value={batchNumber}
|
||||
onChange={(event) => setBatchNumber(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="treatment-edit-withdrawal">Wartezeit (Tage)</Label>
|
||||
<Input
|
||||
id="treatment-edit-withdrawal"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
value={withdrawalPeriodDays}
|
||||
onChange={(event) => setWithdrawalPeriodDays(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="treatment-edit-notes">Notizen</Label>
|
||||
<Textarea
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { updateVarroaMeasurement } from "@/lib/varroa/api";
|
||||
import { VarroaMeasurement, VarroaMethod } from "@/lib/varroa/types";
|
||||
import { VARROA_METHODS, VARROA_METHOD_LABELS } from "@/lib/varroa/labels";
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ColonySelectField } from "@/components/colony-select-field";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -28,9 +30,13 @@ import {
|
||||
export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasurement }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [colonyId, setColonyId] = useState(measurement.colonyId);
|
||||
const [date, setDate] = useState(measurement.date.slice(0, 10));
|
||||
const [method, setMethod] = useState<VarroaMethod>(measurement.method);
|
||||
const [miteCount, setMiteCount] = useState(measurement.miteCount?.toString() ?? "");
|
||||
const [durationMinutes, setDurationMinutes] = useState(
|
||||
measurement.durationMinutes?.toString() ?? "",
|
||||
);
|
||||
const [calculatedValue, setCalculatedValue] = useState(
|
||||
measurement.calculatedValue?.toString() ?? "",
|
||||
);
|
||||
@@ -40,19 +46,26 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
updateVarroaMeasurement(measurement.id, {
|
||||
colonyId,
|
||||
date: new Date(date).toISOString(),
|
||||
method,
|
||||
miteCount: miteCount ? Number(miteCount) : undefined,
|
||||
durationMinutes: durationMinutes ? Number(durationMinutes) : undefined,
|
||||
calculatedValue: calculatedValue ? Number(calculatedValue) : undefined,
|
||||
assessment: assessment || undefined,
|
||||
notes: notes || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
// After re-filing, both the colony it left and the one it joined
|
||||
// show a stale list until each is invalidated.
|
||||
for (const affectedColonyId of new Set([measurement.colonyId, colonyId])) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["colonies", measurement.colonyId, "varroa-measurements"],
|
||||
queryKey: ["colonies", affectedColonyId, "varroa-measurements"],
|
||||
});
|
||||
}
|
||||
setOpen(false);
|
||||
},
|
||||
onError: () => toast.error("Varroamessung konnte nicht gespeichert werden"),
|
||||
});
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
@@ -75,6 +88,11 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
|
||||
<DialogTitle>Varroamessung bearbeiten</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<ColonySelectField
|
||||
id="varroa-edit-colony"
|
||||
value={colonyId}
|
||||
onChange={setColonyId}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="varroa-edit-date">Datum</Label>
|
||||
<Input
|
||||
@@ -107,16 +125,28 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
|
||||
<Input
|
||||
id="varroa-edit-count"
|
||||
type="number"
|
||||
min={0}
|
||||
value={miteCount}
|
||||
onChange={(event) => setMiteCount(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="varroa-edit-duration">Zeitraum (Minuten)</Label>
|
||||
<Input
|
||||
id="varroa-edit-duration"
|
||||
type="number"
|
||||
min={0}
|
||||
value={durationMinutes}
|
||||
onChange={(event) => setDurationMinutes(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="varroa-edit-calculated">Milben pro Tag</Label>
|
||||
<Input
|
||||
id="varroa-edit-calculated"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min={0}
|
||||
value={calculatedValue}
|
||||
onChange={(event) => setCalculatedValue(event.target.value)}
|
||||
/>
|
||||
|
||||
@@ -24,7 +24,10 @@ import { Card, CardContent } from "@/components/ui/card";
|
||||
export default function NewColonyPage() {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: apiaries } = useQuery({ queryKey: ["apiaries"], queryFn: () => fetchApiaries() });
|
||||
const { data: apiaries } = useQuery({
|
||||
queryKey: ["apiaries", { status: "ACTIVE" }],
|
||||
queryFn: () => fetchApiaries({ status: "ACTIVE" }),
|
||||
});
|
||||
|
||||
const [code, setCode] = useState("");
|
||||
const [apiaryId, setApiaryId] = useState("");
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Layers } from "lucide-react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { fetchColonies } from "@/lib/colonies/api";
|
||||
import { ColonyStatus } from "@/lib/colonies/types";
|
||||
import { COLONY_CONDITION_LABELS, COLONY_STATUS_LABELS, COLONY_TYPE_LABELS } from "@/lib/colonies/labels";
|
||||
@@ -35,7 +36,10 @@ export default function ColoniesPage() {
|
||||
queryFn: () => fetchColonies({}),
|
||||
});
|
||||
|
||||
const exportMutation = useMutation({ mutationFn: exportColoniesCsv });
|
||||
const exportMutation = useMutation({
|
||||
mutationFn: exportColoniesCsv,
|
||||
onError: () => toast.error("CSV-Export konnte nicht heruntergeladen werden"),
|
||||
});
|
||||
|
||||
const activeColonies = (colonies ?? []).filter((colony) => colony.deactivatedAt === null);
|
||||
const historyColonies = (colonies ?? []).filter((colony) => colony.deactivatedAt !== null);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Layers,
|
||||
PackageSearch,
|
||||
ShieldAlert,
|
||||
Sprout,
|
||||
} from "lucide-react";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { fetchDashboardAlerts, fetchDashboardSummary } from "@/lib/dashboard/api";
|
||||
@@ -60,6 +61,16 @@ const ALERT_META: Record<
|
||||
icon: PackageSearch,
|
||||
className: "border-warning/40 bg-warning/10",
|
||||
},
|
||||
OVERDUE_SWARM_CHECK: {
|
||||
label: "Schwarmkontrolle überfällig",
|
||||
icon: Sprout,
|
||||
className: "border-destructive/30 bg-destructive/5",
|
||||
},
|
||||
OLD_QUEEN: {
|
||||
label: "Königin überaltert",
|
||||
icon: Crown,
|
||||
className: "border-warning/40 bg-warning/10",
|
||||
},
|
||||
};
|
||||
|
||||
export default function DashboardPage() {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useParams } from "next/navigation";
|
||||
import { Wrench } from "lucide-react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { fetchEquipment, updateEquipment } from "@/lib/equipment/api";
|
||||
import { EquipmentCondition } from "@/lib/equipment/types";
|
||||
import { EQUIPMENT_CONDITIONS, EQUIPMENT_CONDITION_LABELS } from "@/lib/equipment/labels";
|
||||
@@ -32,6 +33,7 @@ export default function EquipmentDetailPage() {
|
||||
void queryClient.invalidateQueries({ queryKey: ["equipment", params.id] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["equipment"] });
|
||||
},
|
||||
onError: () => toast.error("Zustand konnte nicht gespeichert werden"),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export default function AppError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error(error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<Card className="max-w-lg">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="flex size-9 items-center justify-center rounded-xl bg-destructive/10 text-destructive">
|
||||
<AlertTriangle className="size-5" />
|
||||
</span>
|
||||
<CardTitle className="text-lg">Etwas ist schiefgelaufen</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Diese Seite konnte nicht angezeigt werden. Bereits gespeicherte Daten sind davon nicht
|
||||
betroffen. Über die Navigation kommst du zu jeder anderen Seite.
|
||||
</p>
|
||||
<Button onClick={() => reset()} className="w-fit">
|
||||
Erneut versuchen
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -3,12 +3,14 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { updateExtraction } from "@/lib/extractions/api";
|
||||
import { Extraction } from "@/lib/extractions/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 { HarvestSelectField } from "@/components/harvest-select-field";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -20,6 +22,9 @@ import {
|
||||
export function ExtractionEditDialog({ extraction }: { extraction: Extraction }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [harvestIds, setHarvestIds] = useState<string[]>(
|
||||
extraction.harvests.map((entry) => entry.harvestId),
|
||||
);
|
||||
const [date, setDate] = useState(extraction.date.slice(0, 10));
|
||||
const [amount, setAmount] = useState(extraction.amount?.toString() ?? "");
|
||||
const [room, setRoom] = useState(extraction.room ?? "");
|
||||
@@ -30,6 +35,7 @@ export function ExtractionEditDialog({ extraction }: { extraction: Extraction })
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
updateExtraction(extraction.id, {
|
||||
harvestIds,
|
||||
date: new Date(date).toISOString(),
|
||||
amount: amount ? Number(amount) : undefined,
|
||||
room: room || undefined,
|
||||
@@ -41,10 +47,16 @@ export function ExtractionEditDialog({ extraction }: { extraction: Extraction })
|
||||
void queryClient.invalidateQueries({ queryKey: ["extractions", extraction.id] });
|
||||
setOpen(false);
|
||||
},
|
||||
onError: (error: Error) =>
|
||||
toast.error(error.message || "Schleuderung konnte nicht gespeichert werden"),
|
||||
});
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (harvestIds.length === 0) {
|
||||
toast.error("Bitte mindestens eine Ernte auswählen.");
|
||||
return;
|
||||
}
|
||||
mutation.mutate();
|
||||
}
|
||||
|
||||
@@ -63,6 +75,11 @@ export function ExtractionEditDialog({ extraction }: { extraction: Extraction })
|
||||
<DialogTitle>Schleuderung bearbeiten</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<HarvestSelectField
|
||||
idPrefix="extraction-edit-harvest"
|
||||
value={harvestIds}
|
||||
onChange={setHarvestIds}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="extraction-edit-date">Datum</Label>
|
||||
<Input
|
||||
@@ -78,6 +95,7 @@ export function ExtractionEditDialog({ extraction }: { extraction: Extraction })
|
||||
<Input
|
||||
id="extraction-edit-amount"
|
||||
type="number"
|
||||
min={0}
|
||||
value={amount}
|
||||
onChange={(event) => setAmount(event.target.value)}
|
||||
/>
|
||||
@@ -97,6 +115,8 @@ export function ExtractionEditDialog({ extraction }: { extraction: Extraction })
|
||||
<Input
|
||||
id="extraction-edit-water"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={waterContent}
|
||||
onChange={(event) => setWaterContent(event.target.value)}
|
||||
/>
|
||||
|
||||
@@ -123,6 +123,7 @@ export default function NewExtractionPage() {
|
||||
id="extraction-amount"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={0}
|
||||
className="h-11"
|
||||
value={amount}
|
||||
onChange={(event) => setAmount(event.target.value)}
|
||||
@@ -143,6 +144,8 @@ export default function NewExtractionPage() {
|
||||
id="extraction-water"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={0}
|
||||
max={100}
|
||||
className="h-11"
|
||||
value={waterContent}
|
||||
onChange={(event) => setWaterContent(event.target.value)}
|
||||
|
||||
@@ -9,6 +9,8 @@ import { fetchColonies } from "@/lib/colonies/api";
|
||||
import { createFeeding } from "@/lib/feedings/api";
|
||||
import { FeedType } from "@/lib/feedings/types";
|
||||
import { FEED_TYPES, FEED_TYPE_LABELS } from "@/lib/feedings/labels";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import { queueFeeding } from "@/lib/offline/db";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -44,6 +46,7 @@ export default function NewFeedingPage() {
|
||||
const [amount, setAmount] = useState("");
|
||||
const [unit, setUnit] = useState("l");
|
||||
const [product, setProduct] = useState("");
|
||||
const [batchNumber, setBatchNumber] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -59,8 +62,15 @@ export default function NewFeedingPage() {
|
||||
toast.success("Fütterung gespeichert");
|
||||
router.push(`/colonies/${feeding.colonyId}`);
|
||||
},
|
||||
onError: () => {
|
||||
onError: (mutationError, variables) => {
|
||||
if (mutationError instanceof ApiError) {
|
||||
setError("Fütterung konnte nicht gespeichert werden.");
|
||||
return;
|
||||
}
|
||||
void queueFeeding(variables).then(() => {
|
||||
toast.info("Kein Netzwerk – Fütterung wurde offline gespeichert und wird synchronisiert.");
|
||||
router.push(`/colonies/${variables.colonyId}`);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -82,6 +92,7 @@ export default function NewFeedingPage() {
|
||||
amount: Number(amount),
|
||||
unit,
|
||||
product: product || undefined,
|
||||
batchNumber: batchNumber || undefined,
|
||||
notes: notes || undefined,
|
||||
});
|
||||
}
|
||||
@@ -147,6 +158,7 @@ export default function NewFeedingPage() {
|
||||
id="feeding-amount"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={0}
|
||||
required
|
||||
className="h-11"
|
||||
value={amount}
|
||||
@@ -173,6 +185,15 @@ export default function NewFeedingPage() {
|
||||
onChange={(event) => setProduct(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="feeding-batch">Chargennummer</Label>
|
||||
<Input
|
||||
id="feeding-batch"
|
||||
className="h-11"
|
||||
value={batchNumber}
|
||||
onChange={(event) => setBatchNumber(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="feeding-notes">Notizen</Label>
|
||||
<Textarea
|
||||
|
||||
@@ -2,13 +2,23 @@
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { updateHarvest } from "@/lib/harvests/api";
|
||||
import { Harvest } from "@/lib/harvests/types";
|
||||
import { fetchApiaries } from "@/lib/apiaries/api";
|
||||
import { fetchColonies } from "@/lib/colonies/api";
|
||||
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,
|
||||
@@ -20,31 +30,69 @@ import {
|
||||
export function HarvestEditDialog({ harvest }: { harvest: Harvest }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [apiaryId, setApiaryId] = useState(harvest.apiaryId);
|
||||
const [colonyIds, setColonyIds] = useState<string[]>(
|
||||
harvest.colonies.map((entry) => entry.colonyId),
|
||||
);
|
||||
const [date, setDate] = useState(harvest.date.slice(0, 10));
|
||||
const [honeyType, setHoneyType] = useState(harvest.honeyType ?? "");
|
||||
const [grossWeight, setGrossWeight] = useState(harvest.grossWeight?.toString() ?? "");
|
||||
const [netWeight, setNetWeight] = useState(harvest.netWeight?.toString() ?? "");
|
||||
const [honeySuperCount, setHoneySuperCount] = useState(
|
||||
harvest.honeySuperCount?.toString() ?? "",
|
||||
);
|
||||
const [notes, setNotes] = useState(harvest.notes ?? "");
|
||||
|
||||
const { data: apiaries } = useQuery({
|
||||
queryKey: ["apiaries"],
|
||||
queryFn: () => fetchApiaries(),
|
||||
});
|
||||
const { data: colonies } = useQuery({
|
||||
queryKey: ["colonies"],
|
||||
queryFn: () => fetchColonies(),
|
||||
});
|
||||
|
||||
// Colonies of the chosen apiary, plus any colony already on this harvest -
|
||||
// one that was meanwhile moved or dissolved must stay visible, otherwise
|
||||
// saving would silently drop it from the harvest.
|
||||
const selectableColonies = (colonies ?? []).filter(
|
||||
(colony) => colony.apiaryId === apiaryId || colonyIds.includes(colony.id),
|
||||
);
|
||||
|
||||
function toggleColony(colonyId: string, checked: boolean) {
|
||||
setColonyIds((current) =>
|
||||
checked
|
||||
? [...current, colonyId]
|
||||
: current.filter((id) => id !== colonyId),
|
||||
);
|
||||
}
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
updateHarvest(harvest.id, {
|
||||
apiaryId,
|
||||
colonyIds,
|
||||
date: new Date(date).toISOString(),
|
||||
honeyType: honeyType || undefined,
|
||||
grossWeight: grossWeight ? Number(grossWeight) : undefined,
|
||||
netWeight: netWeight ? Number(netWeight) : undefined,
|
||||
honeySuperCount: honeySuperCount ? Number(honeySuperCount) : undefined,
|
||||
notes: notes || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["harvests", harvest.id] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["harvests"] });
|
||||
setOpen(false);
|
||||
},
|
||||
onError: (error: Error) =>
|
||||
toast.error(error.message || "Ernte konnte nicht gespeichert werden"),
|
||||
});
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (colonyIds.length === 0) {
|
||||
toast.error("Bitte mindestens ein Volk auswählen.");
|
||||
return;
|
||||
}
|
||||
mutation.mutate();
|
||||
}
|
||||
|
||||
@@ -63,6 +111,48 @@ export function HarvestEditDialog({ harvest }: { harvest: Harvest }) {
|
||||
<DialogTitle>Ernte bearbeiten</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="harvest-edit-apiary">Bienenstand</Label>
|
||||
<Select
|
||||
value={apiaryId}
|
||||
onValueChange={(value) => setApiaryId(value ?? apiaryId)}
|
||||
>
|
||||
<SelectTrigger id="harvest-edit-apiary" className="w-full">
|
||||
<SelectValue placeholder="Bienenstand wählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(apiaries ?? []).map((apiary) => (
|
||||
<SelectItem key={apiary.id} value={apiary.id}>
|
||||
{apiary.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Beteiligte Völker</Label>
|
||||
{selectableColonies.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Keine Völker an diesem Bienenstand.
|
||||
</p>
|
||||
) : null}
|
||||
{selectableColonies.map((colony) => (
|
||||
<label
|
||||
key={colony.id}
|
||||
className="flex h-11 items-center gap-3 text-sm"
|
||||
htmlFor={`harvest-edit-colony-${colony.id}`}
|
||||
>
|
||||
<input
|
||||
id={`harvest-edit-colony-${colony.id}`}
|
||||
type="checkbox"
|
||||
className="size-5"
|
||||
checked={colonyIds.includes(colony.id)}
|
||||
onChange={(event) => toggleColony(colony.id, event.target.checked)}
|
||||
/>
|
||||
{colony.code}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="harvest-edit-date">Datum</Label>
|
||||
<Input
|
||||
@@ -81,11 +171,22 @@ export function HarvestEditDialog({ harvest }: { harvest: Harvest }) {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Label htmlFor="harvest-edit-gross-weight">Bruttogewicht (kg)</Label>
|
||||
<Input
|
||||
id="harvest-edit-gross-weight"
|
||||
type="number"
|
||||
min={0}
|
||||
value={grossWeight}
|
||||
onChange={(event) => setGrossWeight(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Label htmlFor="harvest-edit-weight">Nettogewicht (kg)</Label>
|
||||
<Input
|
||||
id="harvest-edit-weight"
|
||||
type="number"
|
||||
min={0}
|
||||
value={netWeight}
|
||||
onChange={(event) => setNetWeight(event.target.value)}
|
||||
/>
|
||||
@@ -95,6 +196,7 @@ export function HarvestEditDialog({ harvest }: { harvest: Harvest }) {
|
||||
<Input
|
||||
id="harvest-edit-supers"
|
||||
type="number"
|
||||
min={0}
|
||||
value={honeySuperCount}
|
||||
onChange={(event) => setHoneySuperCount(event.target.value)}
|
||||
/>
|
||||
|
||||
@@ -4,10 +4,13 @@ import { FormEvent, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Boxes } from "lucide-react";
|
||||
import { Boxes, AlertTriangle } from "lucide-react";
|
||||
import { fetchApiaries } from "@/lib/apiaries/api";
|
||||
import { fetchColonies } from "@/lib/colonies/api";
|
||||
import { createHarvest } from "@/lib/harvests/api";
|
||||
import { fetchWithdrawalStatus } from "@/lib/treatments/api";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import { queueHarvest } from "@/lib/offline/db";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -38,6 +41,8 @@ export default function NewHarvestPage() {
|
||||
const [date, setDate] = useState(todayIsoDate());
|
||||
const [colonyIds, setColonyIds] = useState<string[]>([]);
|
||||
const [honeyType, setHoneyType] = useState("");
|
||||
const [honeySuperCount, setHoneySuperCount] = useState("");
|
||||
const [grossWeight, setGrossWeight] = useState("");
|
||||
const [netWeight, setNetWeight] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -45,6 +50,15 @@ export default function NewHarvestPage() {
|
||||
const apiaryColonies = (colonies ?? []).filter(
|
||||
(colony) => colony.apiaryId === apiaryId && colony.status === "ACTIVE",
|
||||
);
|
||||
const apiaryColonyIds = apiaryColonies.map((colony) => colony.id);
|
||||
const { data: withdrawalStatuses } = useQuery({
|
||||
queryKey: ["treatments", "withdrawal-status", apiaryColonyIds],
|
||||
queryFn: () => fetchWithdrawalStatus(apiaryColonyIds),
|
||||
enabled: apiaryColonyIds.length > 0,
|
||||
});
|
||||
const withdrawalByColony = new Map(
|
||||
(withdrawalStatuses ?? []).map((status) => [status.colonyId, status]),
|
||||
);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: createHarvest,
|
||||
@@ -53,8 +67,15 @@ export default function NewHarvestPage() {
|
||||
toast.success("Ernte gespeichert");
|
||||
router.push(`/harvests/${harvest.id}`);
|
||||
},
|
||||
onError: () => {
|
||||
onError: (mutationError, variables) => {
|
||||
if (mutationError instanceof ApiError) {
|
||||
setError("Ernte konnte nicht gespeichert werden.");
|
||||
return;
|
||||
}
|
||||
void queueHarvest(variables).then(() => {
|
||||
toast.info("Kein Netzwerk – Ernte wurde offline gespeichert und wird synchronisiert.");
|
||||
router.push("/harvests");
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -80,6 +101,8 @@ export default function NewHarvestPage() {
|
||||
date: new Date(date).toISOString(),
|
||||
colonyIds,
|
||||
honeyType: honeyType || undefined,
|
||||
honeySuperCount: honeySuperCount ? Number(honeySuperCount) : undefined,
|
||||
grossWeight: grossWeight ? Number(grossWeight) : undefined,
|
||||
netWeight: netWeight ? Number(netWeight) : undefined,
|
||||
notes: notes || undefined,
|
||||
});
|
||||
@@ -135,9 +158,11 @@ export default function NewHarvestPage() {
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex flex-col gap-2">
|
||||
{apiaryColonies.map((colony) => (
|
||||
{apiaryColonies.map((colony) => {
|
||||
const withdrawal = withdrawalByColony.get(colony.id);
|
||||
return (
|
||||
<div key={colony.id} className="flex flex-col gap-1">
|
||||
<label
|
||||
key={colony.id}
|
||||
className="flex h-11 items-center gap-3 text-sm"
|
||||
htmlFor={`harvest-colony-${colony.id}`}
|
||||
>
|
||||
@@ -150,7 +175,16 @@ export default function NewHarvestPage() {
|
||||
/>
|
||||
{colony.code}
|
||||
</label>
|
||||
))}
|
||||
{withdrawal ? (
|
||||
<p className="flex items-center gap-1.5 pl-8 text-xs text-destructive">
|
||||
<AlertTriangle className="size-3.5 shrink-0" />
|
||||
Wartezeit ({withdrawal.product}) bis{" "}
|
||||
{new Date(withdrawal.activeUntil).toLocaleDateString("de-DE")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -162,12 +196,37 @@ export default function NewHarvestPage() {
|
||||
onChange={(event) => setHoneyType(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="harvest-super-count">Honigräume</Label>
|
||||
<Input
|
||||
id="harvest-super-count"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
className="h-11"
|
||||
value={honeySuperCount}
|
||||
onChange={(event) => setHoneySuperCount(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="harvest-gross-weight">Bruttogewicht (kg)</Label>
|
||||
<Input
|
||||
id="harvest-gross-weight"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={0}
|
||||
className="h-11"
|
||||
value={grossWeight}
|
||||
onChange={(event) => setGrossWeight(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="harvest-net-weight">Nettogewicht (kg)</Label>
|
||||
<Input
|
||||
id="harvest-net-weight"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={0}
|
||||
className="h-11"
|
||||
value={netWeight}
|
||||
onChange={(event) => setNetWeight(event.target.value)}
|
||||
|
||||
@@ -70,6 +70,8 @@ export function AddMeasurementDialog({ honeyBatchId }: { honeyBatchId: string })
|
||||
id="measurement-water"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={0}
|
||||
max={100}
|
||||
required
|
||||
value={waterContent}
|
||||
onChange={(event) => setWaterContent(event.target.value)}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { updateHoneyMeasurement } from "@/lib/honey-batches/api";
|
||||
import { HoneyMeasurement } from "@/lib/honey-batches/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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
/**
|
||||
* Corrects a water-content reading. The figure decides whether a batch may be
|
||||
* sold as honey at all, so a slipped digit left standing is worse than a
|
||||
* reading that was never recorded.
|
||||
*/
|
||||
function EditMeasurementForm({
|
||||
measurement,
|
||||
honeyBatchId,
|
||||
onSaved,
|
||||
}: {
|
||||
measurement: HoneyMeasurement;
|
||||
honeyBatchId: string;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [waterContent, setWaterContent] = useState(
|
||||
measurement.waterContent.toString(),
|
||||
);
|
||||
const [measuredAt, setMeasuredAt] = useState(
|
||||
measurement.measuredAt.slice(0, 10),
|
||||
);
|
||||
const [device, setDevice] = useState(measurement.device ?? "");
|
||||
const [temperature, setTemperature] = useState(
|
||||
measurement.temperature?.toString() ?? "",
|
||||
);
|
||||
const [notes, setNotes] = useState(measurement.notes ?? "");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
updateHoneyMeasurement(measurement.id, {
|
||||
waterContent: Number(waterContent),
|
||||
measuredAt: new Date(measuredAt).toISOString(),
|
||||
device: device || undefined,
|
||||
temperature: temperature ? Number(temperature) : undefined,
|
||||
notes: notes || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["honey-batches", honeyBatchId],
|
||||
});
|
||||
onSaved();
|
||||
},
|
||||
onError: (mutationError: Error) => {
|
||||
const message =
|
||||
mutationError.message || "Messung konnte nicht gespeichert werden";
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
mutation.mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="measurement-edit-water">Wassergehalt (%)</Label>
|
||||
<Input
|
||||
id="measurement-edit-water"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min={0}
|
||||
max={100}
|
||||
required
|
||||
value={waterContent}
|
||||
onChange={(event) => setWaterContent(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="measurement-edit-date">Gemessen am</Label>
|
||||
<Input
|
||||
id="measurement-edit-date"
|
||||
type="date"
|
||||
required
|
||||
value={measuredAt}
|
||||
onChange={(event) => setMeasuredAt(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Label htmlFor="measurement-edit-device">Gerät</Label>
|
||||
<Input
|
||||
id="measurement-edit-device"
|
||||
value={device}
|
||||
onChange={(event) => setDevice(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Label htmlFor="measurement-edit-temp">Temperatur (°C)</Label>
|
||||
<Input
|
||||
id="measurement-edit-temp"
|
||||
type="number"
|
||||
value={temperature}
|
||||
onChange={(event) => setTemperature(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="measurement-edit-notes">Notizen</Label>
|
||||
<Textarea
|
||||
id="measurement-edit-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" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Speichert…" : "Speichern"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditMeasurementDialog({
|
||||
measurement,
|
||||
honeyBatchId,
|
||||
}: {
|
||||
measurement: HoneyMeasurement;
|
||||
honeyBatchId: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="sm">
|
||||
<Pencil className="size-3.5" />
|
||||
Bearbeiten
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Messung bearbeiten</DialogTitle>
|
||||
</DialogHeader>
|
||||
{open ? (
|
||||
<EditMeasurementForm
|
||||
measurement={measurement}
|
||||
honeyBatchId={honeyBatchId}
|
||||
onSaved={() => setOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -3,12 +3,14 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { fetchExtraction, updateExtraction } from "@/lib/extractions/api";
|
||||
import { Extraction } from "@/lib/extractions/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 { HarvestSelectField } from "@/components/harvest-select-field";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -27,7 +29,11 @@ function ExtractionEditForm({
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [harvestIds, setHarvestIds] = useState<string[]>(
|
||||
extraction.harvests.map((entry) => entry.harvestId),
|
||||
);
|
||||
const [date, setDate] = useState(extraction.date.slice(0, 10));
|
||||
const [amount, setAmount] = useState(extraction.amount?.toString() ?? "");
|
||||
const [room, setRoom] = useState(extraction.room ?? "");
|
||||
const [waterContent, setWaterContent] = useState(
|
||||
extraction.waterContent?.toString() ?? "",
|
||||
@@ -40,7 +46,9 @@ function ExtractionEditForm({
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
updateExtraction(extraction.id, {
|
||||
harvestIds,
|
||||
date: new Date(date).toISOString(),
|
||||
amount: amount ? Number(amount) : undefined,
|
||||
room: room || undefined,
|
||||
waterContent: waterContent ? Number(waterContent) : undefined,
|
||||
temperature: temperature ? Number(temperature) : undefined,
|
||||
@@ -52,15 +60,26 @@ function ExtractionEditForm({
|
||||
});
|
||||
onSaved();
|
||||
},
|
||||
onError: (error: Error) =>
|
||||
toast.error(error.message || "Schleuderung konnte nicht gespeichert werden"),
|
||||
});
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (harvestIds.length === 0) {
|
||||
toast.error("Bitte mindestens eine Ernte auswählen.");
|
||||
return;
|
||||
}
|
||||
mutation.mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<HarvestSelectField
|
||||
idPrefix="batch-extraction-edit-harvest"
|
||||
value={harvestIds}
|
||||
onChange={setHarvestIds}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="extraction-edit-date">Datum</Label>
|
||||
<Input
|
||||
@@ -70,6 +89,16 @@ function ExtractionEditForm({
|
||||
onChange={(event) => setDate(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="extraction-edit-amount">Menge (kg)</Label>
|
||||
<Input
|
||||
id="extraction-edit-amount"
|
||||
type="number"
|
||||
min={0}
|
||||
value={amount}
|
||||
onChange={(event) => setAmount(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="extraction-edit-room">Raum</Label>
|
||||
<Input
|
||||
@@ -85,6 +114,8 @@ function ExtractionEditForm({
|
||||
id="extraction-edit-water"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min={0}
|
||||
max={100}
|
||||
value={waterContent}
|
||||
onChange={(event) => setWaterContent(event.target.value)}
|
||||
/>
|
||||
|
||||
@@ -2,12 +2,23 @@
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { updateHoneyBatch } from "@/lib/honey-batches/api";
|
||||
import { HoneyBatch } from "@/lib/honey-batches/types";
|
||||
import { HoneyBatch, HoneyBatchStatus } from "@/lib/honey-batches/types";
|
||||
import { HONEY_BATCH_STATUS_LABELS } from "@/lib/honey-batches/labels";
|
||||
import { fetchExtractions } from "@/lib/extractions/api";
|
||||
import { fetchStorageContainers } from "@/lib/storage/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -20,13 +31,32 @@ import {
|
||||
export function HoneyBatchEditDialog({ honeyBatch }: { honeyBatch: HoneyBatch }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [batchNumber, setBatchNumber] = useState(honeyBatch.batchNumber);
|
||||
const [extractionId, setExtractionId] = useState(honeyBatch.extractionId);
|
||||
const [status, setStatus] = useState<HoneyBatchStatus>(honeyBatch.status);
|
||||
const [storageContainerId, setStorageContainerId] = useState(
|
||||
honeyBatch.storageContainerId ?? "",
|
||||
);
|
||||
const [honeyType, setHoneyType] = useState(honeyBatch.honeyType ?? "");
|
||||
const [amount, setAmount] = useState(honeyBatch.amount?.toString() ?? "");
|
||||
const [waterContent, setWaterContent] = useState(honeyBatch.waterContent?.toString() ?? "");
|
||||
|
||||
const { data: extractions } = useQuery({
|
||||
queryKey: ["extractions"],
|
||||
queryFn: () => fetchExtractions(),
|
||||
});
|
||||
const { data: containers } = useQuery({
|
||||
queryKey: ["storage-containers"],
|
||||
queryFn: () => fetchStorageContainers(),
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
updateHoneyBatch(honeyBatch.id, {
|
||||
batchNumber,
|
||||
extractionId,
|
||||
status,
|
||||
storageContainerId: storageContainerId || undefined,
|
||||
honeyType: honeyType || undefined,
|
||||
amount: amount ? Number(amount) : undefined,
|
||||
waterContent: waterContent ? Number(waterContent) : undefined,
|
||||
@@ -36,6 +66,7 @@ export function HoneyBatchEditDialog({ honeyBatch }: { honeyBatch: HoneyBatch })
|
||||
void queryClient.invalidateQueries({ queryKey: ["honey-batches"] });
|
||||
setOpen(false);
|
||||
},
|
||||
onError: () => toast.error("Honigcharge konnte nicht gespeichert werden"),
|
||||
});
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
@@ -58,6 +89,77 @@ export function HoneyBatchEditDialog({ honeyBatch }: { honeyBatch: HoneyBatch })
|
||||
<DialogTitle>Honigcharge bearbeiten</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="batch-edit-number">Chargennummer</Label>
|
||||
<Input
|
||||
id="batch-edit-number"
|
||||
required
|
||||
value={batchNumber}
|
||||
onChange={(event) => setBatchNumber(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="batch-edit-extraction">Schleuderung</Label>
|
||||
<Select
|
||||
value={extractionId}
|
||||
onValueChange={(value) => setExtractionId(value ?? extractionId)}
|
||||
>
|
||||
<SelectTrigger id="batch-edit-extraction" className="w-full">
|
||||
<SelectValue placeholder="Schleuderung wählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(extractions ?? []).map((extraction) => (
|
||||
<SelectItem key={extraction.id} value={extraction.id}>
|
||||
{new Date(extraction.date).toLocaleDateString("de-DE")}
|
||||
{extraction.amount !== null ? ` · ${extraction.amount} kg` : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="batch-edit-container">Lagerbehälter</Label>
|
||||
<Select
|
||||
value={storageContainerId}
|
||||
onValueChange={(value) => setStorageContainerId(value ?? "")}
|
||||
>
|
||||
<SelectTrigger id="batch-edit-container" className="w-full">
|
||||
<SelectValue placeholder="Behälter wählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(containers ?? []).map((container) => (
|
||||
<SelectItem key={container.id} value={container.id}>
|
||||
{container.code}
|
||||
{container.location ? ` · ${container.location}` : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Eine Korrektur wird wie eine Umlagerung in der Lagerhistorie
|
||||
festgehalten.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="batch-edit-status">Status</Label>
|
||||
<Select
|
||||
value={status}
|
||||
onValueChange={(value) => setStatus((value ?? status) as HoneyBatchStatus)}
|
||||
>
|
||||
<SelectTrigger id="batch-edit-status" className="w-full">
|
||||
<SelectValue>{HONEY_BATCH_STATUS_LABELS[status]}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(
|
||||
Object.keys(HONEY_BATCH_STATUS_LABELS) as HoneyBatchStatus[]
|
||||
).map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{HONEY_BATCH_STATUS_LABELS[option]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="batch-edit-honey-type">Sorte</Label>
|
||||
<Input
|
||||
@@ -72,6 +174,7 @@ export function HoneyBatchEditDialog({ honeyBatch }: { honeyBatch: HoneyBatch })
|
||||
id="batch-edit-amount"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={0}
|
||||
value={amount}
|
||||
onChange={(event) => setAmount(event.target.value)}
|
||||
/>
|
||||
@@ -82,6 +185,8 @@ export function HoneyBatchEditDialog({ honeyBatch }: { honeyBatch: HoneyBatch })
|
||||
id="batch-edit-water"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min={0}
|
||||
max={100}
|
||||
value={waterContent}
|
||||
onChange={(event) => setWaterContent(event.target.value)}
|
||||
/>
|
||||
|
||||
Loaded 100 of 165 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user