diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5ac5ce9..59bbc1c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -211,6 +211,10 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige 88. **„1.000“ wird als mehrdeutig abgelehnt**: Die Mengenfelder der Tierarzneimittel-Oberfläche lesen das Komma als Dezimalzeichen und lehnen einen einzelnen Punkt mit genau drei Nachkommastellen („1.000“, „12.345“) sowie jeden Text mit mehreren Trennzeichen ab, weil er Tausender oder Dezimalstelle meinen könnte. Kosten bei falscher Annahme: Wer „1.000“ als eins meint, muss „1“ oder „1,0“ schreiben. Abschnitt 12.11. 89. **Eine neue, ungespeicherte Etikettvorlage prüft nichts, bis sie einen Wert hat**: Der Check-Endpunkt verlangt die Mindest-x-Höhe; das Frontend kennt keinen eigenen Vorgabewert (kein Rechtswert im Code). Eine neue Vorlage startet mit leerem Feld, sendet beim Anlegen keinen Wert (der Server setzt seinen Standard) und zeigt das Panel erst nach Eingabe oder nach dem ersten Speichern. Kosten bei falscher Annahme: Im Designer fehlt bei einer ganz neuen Vorlage die Prüfung, bis der Wert gesetzt ist. Abschnitt 12.11. 90. **Das Prüfbericht-PDF druckt Zählungen, keine Namen**: Es nennt je Punkt nur die Zahl der betroffenen Objekte, nie Stände, Völker, Mittel, Packungen oder Aktenzeichen, damit der Bericht ohne Rechteprüfung der Namen weitergegeben werden kann. Kosten bei falscher Annahme: Wer die Objekte braucht, öffnet die Seite `/compliance` mit den Rechten. Abschnitt 12.10. +91. **Eine Sperre, die heute endet, gilt bis Tagesende; ein Aufheben am selben Tag gibt es nicht**: `endsOn` ist inklusive (aktiv bei `startsOn` ≤ Berliner Tag ≤ `endsOn`), das Backend lehnt `endsOn` vor `startsOn` ab. Die Oberfläche schlägt „gestern“ als Endtag vor; wer heute oder später wählt, bekommt „Die Sperre gilt noch bis einschließlich …“. Kosten bei falscher Annahme: Wer eine Sperre heute am Vormittag aufheben will, hat sie bis Mitternacht Berliner Zeit weiter am Stand. Abschnitt 12.11. +92. **Beim Bearbeiten einer Behandlung wählt die Oberfläche nie selbst eine Packung vor**: Nur das Anlegen schlägt die Packung mit dem frühesten Verfall vor; im Bearbeiten-Dialog steht nach einem Mittelwechsel „Ohne Packung“, bis der Nutzer wählt. Kosten bei falscher Annahme: Wer eine Abbuchung erwartet, muss die Packung ausdrücklich wählen. Abschnitt 12.11. +93. **Die Glocke zeigt für `COMPLIANCE` das Kurz-Etikett „Behörden“**: Die Badges der Glocke sind Einwort-Etiketten („Aufgabe“, „Vorschlag“); „Behörden & Prüfung“ würde die Zeile umbrechen und steht darum nur in `lib/notifications/labels.ts` und der Telegram-Kategorienauswahl. Kosten bei falscher Annahme: Zwei Bezeichnungen für dieselbe Kategorie. Abschnitt 12.11. +94. **Die Dosis ist auf drei Nachkommastellen begrenzt**: Das Eingabefeld lehnt mehr als drei Nachkommastellen mit „Höchstens 3 Nachkommastellen.“ ab (Packungsmengen rechnen auf Tausendstel, `roundToThousandths`). Kosten bei falscher Annahme: Eine feinere Dosis lässt sich nicht erfassen. Abschnitt 12.11. ## 7. Kubernetes @@ -736,7 +740,7 @@ Logs nennen nur Zustellungs-IDs, nie Token, Chat-ID oder Text. Prüfbereitschaft für Tierarzneimittel, Etiketten und Behörden. Rechtswerte (Fristen, Paragraphen, Ziffernhöhen, x-Höhe, Landesrecht Bayern) stehen nie im Code, sondern sind Einstellungen mit dem Vermerk „Annahme – vom Inhaber zu prüfen“; sie sind vor dem Rollout gegen gesetze-im-internet.de und EUR-Lex abzugleichen. Belege, Rechnungsnummern, Anschriften, Koordinaten und Aktenzeichen sehen nur Inhaber von `compliance:read` bzw. `veterinary-medicines:read`; sie erscheinen nie in öffentlichen Endpunkten, im Shop-Feed, in Webhooks oder in Ereignis-Payloads. Entscheidungen des Inhabers vom 2026-10-15: eigene Meldungs-Kategorie `COMPLIANCE`, Stand vor Volk beim Sperren, kein Step-up für die Ausnahme vom Sperrbezirk. -**Stand:** Umgesetzt sind Task 1 (Schema, Rechte, Sperren, Backup), 2 (Tierarzneimittel), 3 (Behandlung aus Packung), 4 (Dokumente), 5 (Etikett-Pflichtangaben), 6 (Druck-Sperre und Probedruck), 7 (Ereignisse), 8 (Sperrbezirk und Sperr-Umbau der Völker) und 9 (Behördenpaket I), 10 (Anzeigen-PDFs), 11 (Hinweise und Tagesauswertung) und 12 (Prüfbereitschaft I), 13 (Prüfbereitschaft II), 14 (Oberfläche Prüfbereitschaft), 15 (Oberfläche Tierarzneimittel) und 17 (Oberfläche Etikett). Task 1 bis 15 und 17 sind erledigt; Task 16, 18 und 19 (Behandlungsmaske, Anzeigen- und Sperrbezirk-Oberfläche, Abschluss) folgen und ergänzen diesen Abschnitt. +**Stand:** Umgesetzt sind Task 1 (Schema, Rechte, Sperren, Backup), 2 (Tierarzneimittel), 3 (Behandlung aus Packung), 4 (Dokumente), 5 (Etikett-Pflichtangaben), 6 (Druck-Sperre und Probedruck), 7 (Ereignisse), 8 (Sperrbezirk und Sperr-Umbau der Völker) und 9 (Behördenpaket I), 10 (Anzeigen-PDFs), 11 (Hinweise und Tagesauswertung) und 12 (Prüfbereitschaft I), 13 (Prüfbereitschaft II), 14 (Oberfläche Prüfbereitschaft), 15 (Oberfläche Tierarzneimittel) 17 (Oberfläche Etikett), 16 (Behandlungsmaske), 18 (Anzeigen und Einstellungen) und 19 (Oberfläche Sperrbezirk). Task 1 bis 19 sind erledigt (T6a Backend: Task 1 bis 13; T6b Frontend: Task 14 bis 19). Offen sind die Browserprüfung aller T6-Seiten, die Rechtswerte-Prüfung durch den Inhaber und das Ausrollen (TASKS.md). ### 12.1 Schema und Rechte @@ -830,7 +834,7 @@ Vier neue Typen im Katalog (`domain-event-types.ts`, 38 Typen), Aggregattyp `Com - **Sperrmuster und 409 „neu laden“.** Die Pfade lesen die Stand-Ids ungesperrt, sperren die Stände `FOR SHARE` aufsteigend, dann die Völker (beim Vereinigen Überlebendes und Aufgelöstes in einer Anweisung), vergleichen unter der Sperre den Stand des Volkes mit dem gelesenen (sonst 409 „Der Stand wurde gerade geändert. Bitte neu laden.“, kein automatischer Neuversuch; ein verschwundenes Volk ist 404), lesen `assertApiaryActive` über `tx` und prüfen erst dann die Sperre. `ApiaryRestrictionsService` (`POST`, `PATCH`) sperrt die Stände `FOR UPDATE`: Ein laufendes Umsetzen und eine neu beginnende Sperre reihen sich hintereinander ein, eine Sperrenänderung wartet auf laufende Umsetzungen. `updateIn` nimmt selbst keinen Standlock; ein Patch mit Standwechsel ohne den Wächter aus `update()` wirft (Programmierfehler). Die Durchsicht-Übernahme (ohne `apiaryId`) sperrt nur das Volk. - **Endpunkte.** `POST /apiary-restrictions` (`compliance:manage`, `@Idempotent`, 1 bis 50 verschiedene Stände, je Stand eine Zeile und ein Audit, eine Transaktion, unbekannter Stand 404 mit Rollback, `endsOn >= startsOn` sonst 400), `PATCH /apiary-restrictions/:id` (Stand `FOR UPDATE`, dann Sperre, `before` danach, `endsOn: null` öffnet wieder), `GET /apiary-restrictions?active=true|false`, `GET /apiaries/:id/restrictions` (`apiaries:read`). - **Einzelheiten nur mit `compliance:read`:** Behörde, Aktenzeichen, Notiz und Ersteller liefern beide GET-Pfade nur dann; mit nur `apiaries:read` besteht eine Zeile aus `id`, `apiaryId`, `disease`, `startsOn`, `endsOn`. `active=true` liefert für alle die schlanke `ActiveRestrictionInfo` (ohne diese Felder). Öffentliche Seiten und der Shop-Feed nennen weder Seuche noch Sperre (e2e mit echter öffentlicher Charge). -- Das Frontend (Task 18/19) bietet den Ausnahmedialog nur mit `compliance:manage` an und sendet `restrictionOverrideReason`. +- Das Frontend (Task 19, Abschnitt 12.11) bietet den Ausnahmedialog nur mit `compliance:manage` an und sendet `restrictionOverrideReason`. ### 12.8 Behördenpaket I: Einstellungen, Bestandsbild, Anzeigen @@ -908,11 +912,11 @@ Task 12 liefert den Rahmen der Prüfbereitschaft mit den ersten sieben Regeln, T ### 12.11 Oberfläche (Frontend) -Die Oberfläche des Compliance-Blocks entsteht in mehreren Tasks; dieser Stand umfasst Prüfbereitschaft (Task 14), Tierarzneimittel (Task 15) und Etikett (Task 17). Behandlungsmaske (Task 16) sowie Anzeigen, Sperrbezirke und Einstellungen (Task 18, 19) folgen. Alle Aktionen sind online-only: Auslöser sind offline deaktiviert und nennen den sichtbaren Grund „Nur mit Verbindung“ (`aria-describedby`). Der Service-Worker-Ausschluss steht in Abschnitt 2. +Die Oberfläche des Compliance-Blocks entstand in Task 14 bis 19: Prüfbereitschaft (14), Tierarzneimittel (15), Behandlungsmaske (16), Etikett (17), Anzeigen und Einstellungen (18) und Sperrbezirk (19). Alle Aktionen sind online-only: Auslöser sind offline deaktiviert und nennen den sichtbaren Grund „Nur mit Verbindung“ (`aria-describedby`). Der Service-Worker-Ausschluss steht in Abschnitt 2. **Prüfbereitschaft (Task 14).** Code in `lib/compliance/` (`types`, `api`, `fix-links`, `errors`, `labels`, `use-readiness`; Query-Schlüssel `['compliance', …]`) und `app/(app)/compliance/`. - **Seite `/compliance`** zeigt `GET /compliance/readiness`. Oben der Status: „Prüfbereit“ (grün) oder „n offen“ (rot), jeweils mit Symbol und Wort. **Jeder offene Punkt ist rot** (Token `destructive`) und trägt Symbol, das Wort „Offen“, die **Schwere als Wort „Pflicht“ oder „Prüfen“** und den Titel; dazu ein Satz Detail und klein die Norm. Betroffene Objekte stehen eingeklappt mit einem „Beheben“-Link je Objekt, „und n weitere“ für den Rest; fehlt das Recht auf die Namen, steht nur „n betroffen“. Erledigte Punkte stehen eingeklappt unter „Erledigt (n)“, Quittierte ruhig mit Nutzer, Datum und Notiz. Die Zuordnung Schwere → Wort und offen → rot liegt allein in `labels.ts` und hat einen eigenen Test. Hauptaktion ist „Nächsten Punkt lösen“ (erster offener Pflicht-Punkt); ist dessen Ziel noch nicht gebaut, heißt sie „Nächsten Punkt ansehen“ und springt auf die Karte (`#item--`). Bei grünem Status steht dort „Bestandsbuch {Jahr} drucken“. Hinter „Mehr“ liegen der Bericht (PDF über `downloadReadinessReport`, als Blob mit Bearer-Header, Dateiname `pruefbericht.pdf`), das Bestandsbuch, Anzeigen, Sperrbezirke und Einstellungen. Downloads sind offline aus. -- **Beheben-Links aus einer Stelle:** `fixLinkFor(itemKey, subject)` in `fix-links.ts` bildet Subjekte auf Seiten ab (Typen ohne Beachtung der Groß-/Kleinschreibung). Die Ziele `/compliance/notices(/[id])`, `/compliance/restrictions` und `/compliance/settings` gibt es erst mit Task 18/19; bis dahin blendet die Menge `ROUTE_AVAILABLE` solche Links aus (`available: false`), und die jeweilige Task trägt ihre Route dort ein. Eine Behandlung verlinkt auf `/colonies/{parentId}`, Stammdaten auf `/settings/automation?tab=social-media` (Reiter „Stammdaten & Web“). `herd-change` führt zur Anzeige (`/compliance/notices`), nicht zur Sperrenliste; `restriction-active` ohne Subjekt zur Sperrenliste. +- **Beheben-Links aus einer Stelle:** `fixLinkFor(itemKey, subject)` in `fix-links.ts` bildet Subjekte auf Seiten ab (Typen ohne Beachtung der Groß-/Kleinschreibung). Die Ziele `/compliance/notices(/[id])`, `/compliance/restrictions` und `/compliance/settings` schaltet `ROUTE_AVAILABLE` seit Task 18 und 19 ein (vorher blendete die Menge solche Links aus, `available: false`). Eine Behandlung verlinkt auf `/colonies/{parentId}`, Stammdaten auf `/settings/automation?tab=social-media` (Reiter „Stammdaten & Web“). `herd-change` führt zur Anzeige (`/compliance/notices`), nicht zur Sperrenliste; `restriction-active` ohne Subjekt zur Sperrenliste. - **Quittieren und Zurücknehmen** (`acknowledge-dialog.tsx`): nur bei `acknowledgeable` und offenem Punkt bzw. quittiertem Punkt, nur mit `compliance:manage`. Notiz 10 bis 2000 Zeichen, vor dem Senden geprüft; Zurücknehmen über `ConfirmDialog`. Fehler laufen über `readinessConflictText`, der die drei Codes `READINESS_FINGERPRINT_STALE`, `READINESS_ITEM_NOT_OPEN` und `READINESS_ACK_REVOKED` mit je eigenem deutschem Text auswertet (der Servertext ist nur Rückfall); bei jedem 409 laden alle `compliance`-Queries neu. Nach Erfolg und Neuladen geht der Fokus auf die Kartenüberschrift (`tabIndex=-1`), eine `role="status"`-Region sagt „Punkt quittiert“ bzw. „Quittung zurückgenommen“. `lib/compliance/errors.ts` liest außerdem `DRAFT_EXISTS` und `RESTRICTED_ZONE`, alles andere läuft über `saveErrorText`; es gibt keine zweite Fehlerlogik. - **Dashboard-Zeile** (`components/today/compliance-line.tsx`): „n Punkte offen in der Prüfbereitschaft“, nur mit `compliance:read` und `openCount > 0`, aus `GET /compliance/readiness/summary` (60 s gecacht, Annahme 86). Schlägt das Laden fehl (kein Netzfehler; offline bleibt es still), steht eine **Fehlerzeile** „Prüfbereitschaft nicht geladen.“ mit „Erneut versuchen“. - **Navigation:** `nav-config.ts` hat „Prüfbereitschaft“ (Symbol `ShieldCheck`, Gruppe „Betrieb“, Recht `compliance:read`). @@ -933,3 +937,31 @@ Die Oberfläche des Compliance-Blocks entsteht in mehreren Tasks; dieser Stand u - **Druckkarte der Abfüllung** (`bottling/[id]/print-labels-card.tsx`): Vor dem Anzeigen läuft `GET /label-templates/:id/check/:bottlingBatchId` (`gcTime: 0`, keine Persistenz, `cache: "no-store"`). Bei Lücken zeigt sie die **rote Lückenliste** (Region, Symbol und „Fehlt:“) mit Links zu Stammdaten, Produkt und Designer (der Produktlink entfällt ohne Produkt an der Abfüllung); „Etiketten als PDF“ ist dann `aria-disabled` (fokussierbar, Grund per `aria-describedby`). Der **Probedruck** („Probedruck (MUSTER)“, `proof=true`) bleibt immer möglich; sein Dateiname enthält „muster“, der des echten Drucks nicht. Antwortet der Druck mit 409 `LABEL_PARTICULARS_MISSING`, zeigt die Karte die Liste `gaps` des Servers und **setzt den Fokus darauf**. 409 `COMPLIANCE_SETTINGS_INVALID` und 403 erscheinen als deutsche Meldung (`labelCheckErrorText`). Ein zweiter Klick während der Wartezeit löst keinen zweiten Aufruf aus. Ohne `label-templates:read` steht nur ein Hinweis, ohne `:print` gibt es keine Druckknöpfe, ohne Netz „Nur mit Verbindung“. Prüf- und Druckaufrufe laufen mit `cache: "no-store"` (`apiFetchBlob` reicht `init` durch). - **Stammdaten.** Im Reiter „Stammdaten & Web“ (Verwaltung > Automatisierung) stehen `foodBusinessName` (200) und `foodBusinessAddress` (300); **leere oder nur aus Leerzeichen bestehende Felder werden als `null` gesendet** (`value.trim() || null`), damit sie serverseitig löschen. „Vom Bienenhalter übernehmen“ füllt nur die Felder, ohne zu speichern. Der Text nennt, dass sie weder im Shop noch auf der Herkunftsseite erscheinen. - **Produkt `legalName`.** Das Feld „Bezeichnung auf dem Etikett (z. B. Blütenhonig)“ steht im Typ `Product`, im Neu-Formular, im Bearbeiten-Dialog und auf der Detailseite; Platzhalter-Schlüssel und deutsche Labels liegen in `lib/label-templates/placeholder-keys.ts` (Wächter-Spec gegen `test/fixtures/placeholder-keys.json`). + +**Behandlung mit Mittel, Packung und Dosis (Task 16).** Formular „Neue Behandlung“, Dialog „Behandlung bearbeiten“ und Sammelerfassung. +- **Mittel.** „Aus dem Katalog wählen“ bietet die aktiven Mittel (nur mit `veterinary-medicines:read`; ohne das Recht gibt es nur „Anderes Mittel“, ohne Fehler); „Anderes Mittel“ ist die letzte Option mit dem Freitextfeld wie bisher. Ein Katalogmittel füllt Wirkstoff, Wartezeit, Methode und Dosis als sichtbaren, bearbeitbaren Vorschlag mit dem Vermerk „aus Katalog“. Er gewinnt gegen die Vorbelegung aus der letzten Behandlung, überschreibt aber nie, was der Nutzer getippt hat; der Server füllt nichts nach. Lade- und Fehlerzustand des Katalogs zeigt `PrefillStatus`, Speichern bleibt möglich. +- **Packung.** Beim Anlegen wird die Packung mit dem frühesten Verfall vorgewählt (mit Hinweis); „Ohne Packung“ bucht nichts und wird nicht wieder überschrieben. **Beim Bearbeiten wählt die Oberfläche nie selbst eine Packung vor** (nach einem Mittelwechsel steht „Ohne Packung“, der Nutzer wählt; Annahme 92). Ist eine Packung gesetzt, stammen Chargennummer, Lieferant, Beleg und Tierarzt aus der Packung (Zeile „aus Packung: …“); die Nutzlast lässt diese Felder weg (der Server lehnt Text daneben mit 400 ab), `product` entfällt mit Katalogmittel. Über „Speichern“ steht die Buchungszeile („Bucht 30 ml je Volk ab (× 6 = 180 ml).“, in der Sammelerfassung mal Anzahl der Völker). Abgelaufene oder zu kleine Packungen warnen, blockieren nie. Der Temperaturhinweis („Heute 9–17 °C. Laut Katalog 12–25 °C.“) kommt aus `GET /apiaries/:id/weather` (`apiaries:read`) und entfällt still, wenn das Wetter fehlt. +- **Dosis.** Dezimalfeld, kein `NumberStepper` (der kennt nur ganze Zahlen): Komma als Dezimalzeichen, mindestens 0,001, **höchstens 3 Nachkommastellen** („Höchstens 3 Nachkommastellen.“, Annahme 94), „1.000“ wird als mehrdeutig abgelehnt (Annahme 88). +- **Mittelwechsel beim Bearbeiten.** Der Dialog nutzt dieselbe Katalog-Vorbelegung wie das Anlegen; was die Behandlung schon enthält, gehört dem Nutzer, nur leere Felder füllt der Katalog. Eine vorhandene Wartezeit wird beim Wechsel nie still ersetzt: Weicht sie vom Katalogwert des neuen Mittels ab (oder kennt der Katalog keinen), steht am Feld „Wartezeit stammt vom vorherigen Mittel – bitte prüfen.“, dazu der Knopf „aus Katalog übernehmen“ (nur mit Katalogwert) als ausdrückliche Übernahme. Eine Eingabe im Feld entfernt die Warnung. +- **Patch.** `medicinePatch` sendet die Mittel-Gruppe (`product`, `medicineId`, `medicineLotId`, `doseQuantity`, vier Packungstexte) nur, wenn sich in ihr etwas geändert hat; eine reine Notiz-Änderung bleibt so immer möglich. +- **Fehler und Deep-Link.** Anlegen und Dialog lesen Fehler über **einen** Leser, `saveErrorText` (kennt auch Feldfehler-Listen des Servers). `?editTreatment=` an der Volksseite öffnet den Dialog dieser Behandlung; findet die geladene Liste die ID nicht, sagt eine Live-Region „Behandlung nicht gefunden.“. +- **Entwurf wiederherstellen** (`treatments/new`): Ist das Mittel des Entwurfs nicht mehr im Katalog, nicht aktiv oder fehlt das Leserecht, fallen `medicineId` und `medicineLotId` weg, `product` bleibt als Freitext, und eine Statuszeile sagt „Das Mittel aus dem Entwurf ist im Katalog nicht mehr aktiv. Bitte wähle es neu.“ Ist die Katalogliste noch nicht geladen, wird nichts verworfen. + +**Anzeigen und Einstellungen (Task 18).** Seiten unter `app/(app)/compliance/`, Fehlertexte über `complianceErrorText` (`lib/compliance/errors.ts`). +- **Liste `/compliance/notices`:** Anzeigen ans Veterinäramt (Art, Jahr, Status mit Symbol und Wort, Völkerzahl, Stichtag), Filter nach Art und **Jahresfilter** (vier Ziffern, sonst Hinweis am Feld; Server-Query `year`). Ab 200 Einträgen steht der Hinweis „Es werden die neuesten 200 angezeigt.“ (Obergrenze der Liste). +- **Anlegen:** „Anzeige anlegen“ (nur `compliance:manage`) öffnet einen Dialog (Erstanzeige, Bestandsänderung, TSK-Zählung mit Jahr; „Bereits angezeigt“ mit Datum legt direkt eine gesendete an). Der Aufruf trägt einen `Idempotency-Key`. Gibt es schon einen Entwurf, antwortet der Server 409 `DRAFT_EXISTS` mit `existingId`; der Dialog verlinkt auf den vorhandenen Entwurf. +- **Detail `/compliance/notices/[id]`** mit drei sichtbaren Schritten (1. PDF öffnen, 2. ans Amt schicken, 3. hier bestätigen; je Symbol und Wort Jetzt/Danach/Erledigt). Der **PDF-Download** (authentifiziert, kein Token im Link) meldet in der `aria-live`-Region „PDF heruntergeladen. Schick es jetzt ans Amt.“ und setzt den Fokus auf die neue Hauptaktion. **„Als gesendet markieren“ ist im Entwurf immer erreichbar**: als zweite Aktion (Umrissknopf neben „Neu zählen“), nach dem PDF als Hauptaktion; so geht es auch nach einem Neuladen ohne erneutes PDF. Das Sendedatum und „Gesendet am“ nutzen den **Berliner Kalendertag** (`berlinCalendarDay`); ein geleertes Datum zeigt „Das Datum kann nicht entfernt werden.“ „Neu zählen“ und „Anzeige stornieren“ gibt es nur im Entwurf, „Wieder öffnen“ nur bei gesendeter Anzeige; Kopie und Eingangsbestätigung sind Dokumente der Art `AUTHORITY_NOTICE`. Mit nur `compliance:read` fehlen die Aktionsknöpfe. `complianceErrorText` übersetzt 403 („Dazu fehlt dir das Recht.“) und 5xx („Der Server hat einen Fehler gemeldet. Versuch es gleich noch einmal.“) und fügt eine 400-Antwort mit `message: string[]` zusammen; Nests Englisch erscheint nie. +- **Einstellungen `/compliance/settings`:** Veterinäramt, Erinnerungs-Schalter (Völkerzahl, Stand), TSK-Stichtag und Meldefrist, Mitgliedsnummer. Jeder Rechts- und Fristwert trägt am Feld „Annahme – bitte prüfen“. Stichtag und Meldefrist (leer erlaubt) prüft der Client vor dem Senden auf `MM-TT` (`isValidMonthDay`, Tage je Monat im Nichtschaltjahr, „02-29“ ungültig; Hinweis „Bitte einen Tag im Format MM-TT eingeben, zum Beispiel 03-31. Den 29. Februar gibt es hier nicht.“, `aria-describedby`, kein Request). Unter „Erweitert“ stehen die gezählten Völkerarten und der **Editor der Ziffernregeln** (1 bis 10 Regeln; „Regel hinzufügen“ fügt vor der letzten ein, „Entfernen, Regel n“; Höhe 0,5 bis 20 mm, Obergrenzen > 0 und aufsteigend, die letzte Regel hat keine Grenze und geht als `null` hinaus). Die Schranken sind die Grenzen des Backends (`assertDigitRules`, Konstanten in `compliance-settings.service.ts`), keine Rechtswerte; Fehler stehen an der Zeile (`role="alert"`) und als Satz „Bitte die Ziffernhöhen unter „Erweitert“ prüfen.“, weil der Abschnitt eingeklappt sein kann. Ohne `compliance:manage` sind die Felder nur lesbar. +- **Formular-Schutz:** Ein Hintergrund-Refetch setzt eine Eingabe nie zurück. Ohne Eingabe werden neue Serverdaten still übernommen; mit ungespeicherter Eingabe bleibt sie stehen, und „Daten wurden geändert – neu laden?“ mit „Neu laden“ erscheint. Nach eigenem Speichern wird der Zustand direkt übernommen. +- **Meldungskategorie `COMPLIANCE`** („Behörden & Prüfung“) steht in `lib/notifications/labels.ts` und damit in der Kategorienauswahl der Telegram-Karte. Die Glocke zeigt dafür das Kurz-Etikett „Behörden“ (Annahme 93). + +**Sperrbezirk (Task 19).** Aktive Sperren lädt jede Seite einmal (`useActiveRestrictions`, Schlüssel `['compliance','restrictions','active']`, `staleTime` 30 s, Neuladen beim Fokus; Umsetzen- und Vereinigen-Dialog laden beim Öffnen neu). Der Server entscheidet endgültig, der Browser blendet nur früh aus. +- **Seite `/compliance/restrictions`** (Recht `compliance:read`, Pflege mit `compliance:manage`; ohne Leserecht `NoComplianceAccess` „Dazu fehlt dir das Recht.“ und keine Abfragen) mit drei Abschnitten **Aktiv / Geplant / Beendet** (Symbol und Wort; `restrictionState` rechnet nach dem Berliner Kalendertag, eine Sperre mit Beginn in der Zukunft steht unter „Geplant“, nie unter „Beendet“). Die Live-Region sagt nur Laden und Fehler. Die Verfügung hängt als Dokument (`APIARY_RESTRICTION`, `DocumentUploadCard`, schreibend nur mit `compliance:manage`; ohne Leserecht entfällt die Zeile „Verfügung“). +- **Anlegen:** „Sperrbezirk anlegen“ legt je gewähltem Stand (1 bis 50) einen Eintrag an (Seuche: Amerikanische Faulbrut, Kleiner Beutenkäfer oder Andere mit Freitext; Beginn, optional Ende, Behörde, Aktenzeichen, Notizen) mit `Idempotency-Key`. +- **Beenden:** Der Dialog setzt nur `endsOn` (der Eintrag wird nie gelöscht). **Vorgabe ist „gestern“** (nicht vor dem Beginn); `endsOn` gilt **einschließlich**. Ist der gewählte Endtag heute oder später, zeigen Hinweis und Toast „Die Sperre gilt noch bis einschließlich TT.MM.JJJJ.“. **Ein Aufheben am selben Tag ist nicht möglich** (Annahme 91): Ist die Sperre heute aufgehoben, trägt man gestern ein, außer sie begann heute, dann gilt sie bis einschließlich heute. +- **Rotes Band** am Stand, solange eine Sperre aktiv ist, mit dem Link „Sperrbezirke ansehen“ (Link nur mit `compliance:read`). +- **Wächter** bei Umsetzen (Quell- und Zielstand), Vereinigen über verschiedene Stände, Standkorrektur am Volk und neuem Volk: rote Meldung („Stand {Name} liegt im Sperrbezirk ({Krankheit}, seit {Datum}). … ist gesperrt.“; ohne Servertext der Rückfall „Der Stand liegt im Sperrbezirk.“) mit Symbol und Wort in einer persistenten `aria-live`-Region. Der Hauptknopf ist `aria-disabled` mit `aria-describedby` auf den Grund (bleibt fokussierbar, Klick und Submit sind gesperrt; `ConfirmDialog` hat dafür die additive Eigenschaft `triggerAriaDisabled`). Ableger (`split`) bleiben frei. Der Umsetzen-Dialog trägt den festen Hinweis zu BienSeuchV § 5 (Gesundheitsbescheinigung, im Zweifel beim Veterinäramt nachfragen) und keine Rechtswerte. +- **Ausnahme.** Nur mit `compliance:manage` erscheint der aufklappbare Abschnitt „Ausnahme mit Begründung“ (10 bis 500 Zeichen; Leerzeichen allein genügen nicht); die Begründung gilt erst nach der Rückfrage im `ConfirmDialog`, ohne Passwort-Abfrage (Inhaber-Entscheidung 2026-10-15). `restrictionOverrideReason` wird nur gesendet, wenn die Ausnahme genutzt wird; ändern sich die Sperren, verfällt sie. Ohne das Recht wäre die Antwort immer 403, darum wird das Feld nicht angeboten. +- **Ernte:** nur die Warnung „Im Sperrbezirk gelten Auflagen auch für Honig und Waben. Verfügung prüfen.“; sie speichert immer. +- **409.** `RESTRICTED_ZONE` und „Der Stand wurde gerade geändert. Bitte neu laden.“ erscheinen mit dem Servertext über dem Knopf, der Fokus springt auf die Meldung, die aktiven Sperren laden neu, und ein Knopf „Neu laden“ (Umsetzen, Vereinigen, Standkorrektur) lädt Volk und Stände neu und löscht die Meldung. Kein automatischer Neuversuch. +- **Service Worker.** Die Sperrbezirk-Pfade sind seit Task 14 nie zwischengespeichert; offline fehlt die frühe Sperre, der Server entscheidet. diff --git a/ApiCore-Frontend/src/app/(app)/apiaries/[id]/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/apiaries/[id]/page.spec.tsx index e2f3f84..78e82a1 100644 --- a/ApiCore-Frontend/src/app/(app)/apiaries/[id]/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/apiaries/[id]/page.spec.tsx @@ -2,12 +2,17 @@ import { describe, expect, it, vi } from "vitest"; import { render, screen } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { fetchApiary } from "@/lib/apiaries/api"; +import { fetchActiveRestrictions } from "@/lib/compliance/restrictions-api"; import ApiaryDetailPage from "./page"; import { useAuth } from "@/lib/auth/auth-context"; vi.mock("next/navigation", () => ({ useParams: () => ({ id: "a-1" }) })); vi.mock("@/lib/auth/auth-context", () => ({ useAuth: vi.fn() })); vi.mock("@/lib/apiaries/api", () => ({ fetchApiary: vi.fn() })); +vi.mock("@/lib/compliance/restrictions-api", async (importOriginal) => ({ + ...(await importOriginal()), + fetchActiveRestrictions: vi.fn().mockResolvedValue([]), +})); vi.mock("@/lib/export/api", () => ({ exportApiaryAnnualReportPdf: vi.fn(), exportTreatmentLogPdf: vi.fn(), @@ -105,3 +110,33 @@ describe("ApiaryDetailPage", () => { expect(screen.queryByText(/Das Bestandsbuch braucht das Recht/)).not.toBeInTheDocument(); }); }); + +describe("ApiaryDetailPage – Sperrbezirk", () => { + const restriction = { + id: "r-1", + apiaryId: "a-1", + apiaryName: "Obstwiese", + disease: "Amerikanische Faulbrut", + startsOn: "2026-10-01", + }; + + it("the apiary page shows a red band with a link while a restriction is active", async () => { + vi.mocked(fetchActiveRestrictions).mockResolvedValue([restriction]); + renderPage(["compliance:read"]); + + const text = await screen.findByText(/Stand Obstwiese liegt im Sperrbezirk/); + expect(text.closest("[data-restriction-band]")).toHaveClass("text-destructive"); + expect(screen.getByRole("link", { name: "Sperrbezirke ansehen" })).toHaveAttribute( + "href", + "/compliance/restrictions", + ); + }); + + it("shows no band when only another apiary is restricted", async () => { + vi.mocked(fetchActiveRestrictions).mockResolvedValue([{ ...restriction, apiaryId: "a-9" }]); + renderPage([]); + + await screen.findByText("Saison von Obstwiese"); + expect(screen.queryByText(/liegt im Sperrbezirk/)).not.toBeInTheDocument(); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/apiaries/[id]/page.tsx b/ApiCore-Frontend/src/app/(app)/apiaries/[id]/page.tsx index 172fb3f..85097ac 100644 --- a/ApiCore-Frontend/src/app/(app)/apiaries/[id]/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/apiaries/[id]/page.tsx @@ -9,6 +9,9 @@ import { useAuth } from "@/lib/auth/auth-context"; import { exportApiaryAnnualReportPdf, exportTreatmentLogPdf } from "@/lib/export/api"; import { QrCodeDialog } from "@/components/qr-code-dialog"; import { DocumentUploadCard } from "@/components/document-upload-card"; +import { RestrictionBand } from "@/components/compliance/restriction-notice"; +import { restrictionsAmong } from "@/lib/compliance/restrictions-api"; +import { useActiveRestrictions } from "@/lib/compliance/use-active-restrictions"; import { ApiarySeason } from "@/components/phenology/apiary-season"; import { DetailHeader, DetailQueryFallback, DetailStatus } from "@/components/detail-header"; import { ApiaryColoniesCard, StartRoundButton } from "./apiary-colonies-card"; @@ -26,6 +29,7 @@ export default function ApiaryDetailPage() { queryFn: () => fetchApiary(params.id), }); const apiary = apiaryQuery.data; + const { restrictions } = useActiveRestrictions(); const exportReportMutation = useMutation({ mutationFn: () => exportApiaryAnnualReportPdf(params.id, CURRENT_YEAR), @@ -84,6 +88,10 @@ export default function ApiaryDetailPage() { ]} {...BACK} /> + {!canReadMedicines ? (

Das Bestandsbuch braucht das Recht „Tierarzneimittel lesen“. diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.spec.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.spec.tsx index 9556946..eda8797 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.spec.tsx @@ -1,9 +1,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { fireEvent, render, screen, within } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ColonyActions } from "./colony-actions"; -import { fetchColonies, mergeColony, splitColony } from "@/lib/colonies/api"; +import { fetchColonies, mergeColony, moveColony, splitColony } from "@/lib/colonies/api"; +import { fetchApiaries } from "@/lib/apiaries/api"; +import { fetchActiveRestrictions } from "@/lib/compliance/restrictions-api"; import { ApiError } from "@/lib/auth/api-client"; import { todayLocalIsoDate } from "@/lib/dates"; import type { Colony, ColonyStatus } from "@/lib/colonies/types"; @@ -22,6 +24,15 @@ vi.mock("@/lib/queens/api", () => ({ assignQueenColony: vi.fn(), })); vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); +vi.mock("@/lib/compliance/restrictions-api", async (importOriginal) => ({ + ...(await importOriginal()), + fetchActiveRestrictions: vi.fn().mockResolvedValue([]), +})); +let permissions: string[] = []; +vi.mock("@/lib/auth/auth-context", async (importOriginal) => ({ + ...(await importOriginal()), + useAuth: () => ({ hasPermission: (key: string) => permissions.includes(key) }), +})); function colony(id: string, code: string, status: ColonyStatus = "ACTIVE"): Colony { return { @@ -247,3 +258,258 @@ describe("ColonyActions – split", () => { expect(splitColony).not.toHaveBeenCalled(); }); }); + +const FAULBRUT = "Amerikanische Faulbrut"; +const REASON = "Amtstierarzt hat das Umsetzen mündlich erlaubt."; +const HINT = + "Beim Umsetzen an einen anderen Standort kann eine Gesundheitsbescheinigung nötig sein (BienSeuchV § 5). Im Zweifel beim Veterinäramt nachfragen."; + +function restriction(apiaryId: string, apiaryName: string) { + return { id: `r-${apiaryId}`, apiaryId, apiaryName, disease: FAULBRUT, startsOn: "2026-10-01" }; +} + +describe("ColonyActions – Sperrbezirk beim Umsetzen", () => { + beforeEach(() => { + permissions = []; + vi.mocked(fetchApiaries).mockResolvedValue([ + { id: "apiary-1", name: "Obstwiese", status: "ACTIVE" }, + { id: "apiary-2", name: "Hausgarten", status: "ACTIVE" }, + ] as never); + vi.mocked(fetchActiveRestrictions).mockResolvedValue([]); + vi.mocked(moveColony).mockReset(); + vi.mocked(moveColony).mockResolvedValue(target); + }); + + async function openMove() { + renderActions(); + await userEvent.click(screen.getByRole("button", { name: "Umsetzen" })); + return screen.findByRole("dialog"); + } + + async function chooseTarget(dialog: HTMLElement, name = "Hausgarten") { + await userEvent.click(within(dialog).getByRole("combobox")); + await userEvent.click(await screen.findByRole("option", { name })); + } + + const submit = (dialog: HTMLElement) => within(dialog).getByRole("button", { name: "Umsetzen" }); + + it("always shows the exact BienSeuchV § 5 hint in the move dialog", async () => { + const dialog = await openMove(); + expect(within(dialog).getByText(HINT)).toBeInTheDocument(); + }); + + it("moves without any override field when nothing is restricted", async () => { + permissions = ["compliance:manage"]; + const dialog = await openMove(); + await chooseTarget(dialog); + + expect(within(dialog).queryByRole("button", { name: /Ausnahme mit Begründung/ })).not.toBeInTheDocument(); + await userEvent.click(submit(dialog)); + + expect(moveColony).toHaveBeenCalledWith("colony-1", { apiaryId: "apiary-2" }); + }); + + it("the move dialog shows the red notice and disables Umsetzen while the target apiary is restricted", async () => { + vi.mocked(fetchActiveRestrictions).mockResolvedValue([restriction("apiary-2", "Hausgarten")]); + const dialog = await openMove(); + await chooseTarget(dialog); + + const notice = await within(dialog).findByText( + `Stand Hausgarten liegt im Sperrbezirk (${FAULBRUT}, seit 01.10.2026). Umsetzen ist gesperrt.`, + ); + expect(notice.closest("[data-restriction-notice]")).toHaveClass("text-destructive"); + expect(submit(dialog)).toHaveAttribute("aria-disabled", "true"); + expect(submit(dialog).getAttribute("aria-describedby")).toBe( + notice.closest("[data-restriction-notice]")?.id, + ); + }); + + it("disables Umsetzen from the start while the source apiary is restricted", async () => { + vi.mocked(fetchActiveRestrictions).mockResolvedValue([restriction("apiary-1", "Obstwiese")]); + const dialog = await openMove(); + + expect(await within(dialog).findByText(/Stand Obstwiese liegt im Sperrbezirk/)).toBeInTheDocument(); + await chooseTarget(dialog); + expect(submit(dialog)).toHaveAttribute("aria-disabled", "true"); + }); + + it("hides the override field without compliance:manage", async () => { + vi.mocked(fetchActiveRestrictions).mockResolvedValue([restriction("apiary-2", "Hausgarten")]); + const dialog = await openMove(); + await chooseTarget(dialog); + + await within(dialog).findByText(/liegt im Sperrbezirk/); + expect(within(dialog).queryByRole("button", { name: /Ausnahme mit Begründung/ })).not.toBeInTheDocument(); + expect(submit(dialog)).toHaveAttribute("aria-disabled", "true"); + }); + + it("enables Umsetzen after a reason of 10+ characters and a confirmation, and sends the reason", async () => { + permissions = ["compliance:manage"]; + vi.mocked(fetchActiveRestrictions).mockResolvedValue([restriction("apiary-2", "Hausgarten")]); + const dialog = await openMove(); + await chooseTarget(dialog); + + await userEvent.click(await within(dialog).findByRole("button", { name: /Ausnahme mit Begründung/ })); + await userEvent.type(within(dialog).getByLabelText(/Begründung der Ausnahme/), REASON); + expect(submit(dialog)).toHaveAttribute("aria-disabled", "true"); + await userEvent.click(within(dialog).getByRole("button", { name: "Ausnahme nutzen" })); + await userEvent.click(await screen.findByRole("button", { name: "Ausnahme bestätigen" })); + + await waitFor(() => expect(submit(dialog)).not.toHaveAttribute("aria-disabled")); + await userEvent.click(submit(dialog)); + + expect(moveColony).toHaveBeenCalledWith("colony-1", { + apiaryId: "apiary-2", + restrictionOverrideReason: REASON, + }); + }); + + it("shows the server 409 RESTRICTED_ZONE message and puts the focus on it when a race slips past", async () => { + vi.mocked(moveColony).mockRejectedValue( + new ApiError(409, "Der Bienenstand „Hausgarten“ liegt im Sperrbezirk.", { + code: "RESTRICTED_ZONE", + message: "Der Bienenstand „Hausgarten“ liegt im Sperrbezirk.", + restrictionIds: ["r-9"], + restrictions: [], + }), + ); + const dialog = await openMove(); + await chooseTarget(dialog); + await userEvent.click(submit(dialog)); + + const alert = await within(dialog).findByRole("alert"); + expect(alert).toHaveTextContent("Der Bienenstand „Hausgarten“ liegt im Sperrbezirk."); + await waitFor(() => expect(alert).toHaveFocus()); + }); + + it("shows the 409 text of an apiary that changed meanwhile", async () => { + vi.mocked(moveColony).mockRejectedValue( + new ApiError(409, "Der Stand wurde gerade geändert. Bitte neu laden."), + ); + const dialog = await openMove(); + await chooseTarget(dialog); + await userEvent.click(submit(dialog)); + + expect(await within(dialog).findByRole("alert")).toHaveTextContent( + "Der Stand wurde gerade geändert. Bitte neu laden.", + ); + }); +}); + +describe("ColonyActions – Sperrbezirk beim Vereinigen", () => { + const faraway = { ...colony("colony-2", "V-02"), apiaryId: "apiary-2" }; + + beforeEach(() => { + permissions = ["compliance:manage"]; + vi.mocked(fetchColonies).mockResolvedValue([target, faraway]); + vi.mocked(mergeColony).mockReset(); + vi.mocked(mergeColony).mockResolvedValue(target); + vi.mocked(fetchActiveRestrictions).mockResolvedValue([restriction("apiary-2", "Hausgarten")]); + }); + + async function pickMerged() { + renderActions(); + await userEvent.click(screen.getByRole("button", { name: "Vereinigen" })); + const dialog = await screen.findByRole("dialog"); + await userEvent.click(within(dialog).getByRole("combobox")); + await userEvent.click(await screen.findByRole("option", { name: "V-02" })); + return dialog; + } + + it("shows the notice and keeps Vereinigen off until the override is used", async () => { + const dialog = await pickMerged(); + + expect(await within(dialog).findByText(/Stand Hausgarten liegt im Sperrbezirk/)).toBeInTheDocument(); + const trigger = within(dialog).getByRole("button", { name: "Vereinigen" }); + expect(trigger).toHaveAttribute("aria-disabled", "true"); + expect(trigger.getAttribute("aria-describedby")).toBeTruthy(); + }); + + it("sends restrictionOverrideReason with the merge only when the override is used", async () => { + const dialog = await pickMerged(); + await userEvent.click(await within(dialog).findByRole("button", { name: /Ausnahme mit Begründung/ })); + await userEvent.type(within(dialog).getByLabelText(/Begründung der Ausnahme/), REASON); + await userEvent.click(within(dialog).getByRole("button", { name: "Ausnahme nutzen" })); + await userEvent.click(await screen.findByRole("button", { name: "Ausnahme bestätigen" })); + await waitFor(() => expect(within(dialog).getByRole("button", { name: "Vereinigen" })).not.toHaveAttribute("aria-disabled")); + await userEvent.click(within(dialog).getByRole("button", { name: "Vereinigen" })); + await userEvent.click(await screen.findByRole("button", { name: "V-02 in V-01 aufgehen lassen" })); + + expect(mergeColony).toHaveBeenCalledWith("colony-1", { + mergedColonyIds: ["colony-2"], + restrictionOverrideReason: REASON, + }); + }); + + it("does not block a merge inside one restricted apiary", async () => { + vi.mocked(fetchColonies).mockResolvedValue([target, colony("colony-2", "V-02")]); + vi.mocked(fetchActiveRestrictions).mockResolvedValue([restriction("apiary-1", "Obstwiese")]); + const dialog = await pickMerged(); + + await waitFor(() => expect(within(dialog).getByRole("button", { name: "Vereinigen" })).not.toHaveAttribute("aria-disabled")); + expect(within(dialog).queryByText(/liegt im Sperrbezirk/)).not.toBeInTheDocument(); + }); +}); + +describe("ColonyActions – Sperrbezirk, Fehlerfälle", () => { + beforeEach(() => { + permissions = []; + vi.mocked(fetchApiaries).mockResolvedValue([ + { id: "apiary-1", name: "Obstwiese", status: "ACTIVE" }, + { id: "apiary-2", name: "Hausgarten", status: "ACTIVE" }, + ] as never); + vi.mocked(fetchActiveRestrictions).mockResolvedValue([]); + vi.mocked(moveColony).mockReset(); + }); + + async function failMove(error: ApiError) { + vi.mocked(moveColony).mockRejectedValue(error); + renderActions(); + await userEvent.click(screen.getByRole("button", { name: "Umsetzen" })); + const dialog = await screen.findByRole("dialog"); + await userEvent.click(within(dialog).getByRole("combobox")); + await userEvent.click(await screen.findByRole("option", { name: "Hausgarten" })); + await userEvent.click(within(dialog).getByRole("button", { name: "Umsetzen" })); + return dialog; + } + + it("falls back to a fixed sentence when RESTRICTED_ZONE comes without a message", async () => { + const dialog = await failMove( + new ApiError(409, "", { code: "RESTRICTED_ZONE", restrictionIds: ["r-1"], restrictions: [] }), + ); + expect(await within(dialog).findByRole("alert")).toHaveTextContent("Der Stand liegt im Sperrbezirk."); + }); + + it("offers Neu laden after \"Bitte neu laden\" and reloads the colony", async () => { + const dialog = await failMove(new ApiError(409, "Der Stand wurde gerade geändert. Bitte neu laden.")); + vi.mocked(fetchColonies).mockClear(); + await userEvent.click(await within(dialog).findByRole("button", { name: "Neu laden" })); + + expect(within(dialog).queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("does not offer Neu laden for a restricted zone", async () => { + const dialog = await failMove( + new ApiError(409, "Der Bienenstand liegt im Sperrbezirk.", { code: "RESTRICTED_ZONE" }), + ); + await within(dialog).findByRole("alert"); + expect(within(dialog).queryByRole("button", { name: "Neu laden" })).not.toBeInTheDocument(); + }); + + it("keeps the blocked Umsetzen button reachable by keyboard and does not move on click", async () => { + vi.mocked(fetchActiveRestrictions).mockResolvedValue([restriction("apiary-2", "Hausgarten")]); + renderActions(); + await userEvent.click(screen.getByRole("button", { name: "Umsetzen" })); + const dialog = await screen.findByRole("dialog"); + await userEvent.click(within(dialog).getByRole("combobox")); + await userEvent.click(await screen.findByRole("option", { name: "Hausgarten" })); + await within(dialog).findByText(/liegt im Sperrbezirk/); + const button = within(dialog).getByRole("button", { name: "Umsetzen" }); + + expect(button).not.toBeDisabled(); + button.focus(); + expect(button).toHaveFocus(); + await userEvent.click(button); + expect(moveColony).not.toHaveBeenCalled(); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.tsx index 9a53847..e37c3fb 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.tsx @@ -36,6 +36,9 @@ import { } from "@/components/ui/dialog"; import { EnumSelect } from "@/components/form/enum-select"; import { RecordSelect } from "@/components/form/record-select"; +import { RestrictionGuardPanel } from "@/components/compliance/restriction-guard"; +import { BLOCKED_TEXT, MoveHint } from "@/components/compliance/restriction-notice"; +import { useFocusWhen, useRestrictionGuard } from "@/lib/compliance/use-restriction-guard"; function invalidateColony( queryClient: ReturnType, @@ -46,21 +49,49 @@ function invalidateColony( void queryClient.invalidateQueries({ queryKey: ["colonies", colonyId, "history"] }); } +/** "Neu laden" after a 409: the colony may have been moved meanwhile. */ +function reloadColony( + queryClient: ReturnType, + colonyId: string, + clearError: () => void, +): void { + invalidateColony(queryClient, colonyId); + void queryClient.invalidateQueries({ queryKey: ["apiaries"] }); + clearError(); +} + /** A failed action explains itself right above the button that caused it. */ function ActionFooter({ error, + focusError = false, + onReload, children, }: { error: string | null; + /** A 409: the focus jumps to the message. */ + focusError?: boolean; + /** After a 409 "Bitte neu laden": loads the colony again and clears the message. */ + onReload?: () => void; children: ReactNode; }) { + const errorRef = useFocusWhen(error !== null && focusError); return ( {error ? ( -

+

{error}

) : null} + {error && onReload ? ( + + ) : null} {children} ); @@ -71,14 +102,18 @@ function MoveDialog({ colony }: { colony: Colony }) { const [open, setOpen] = useState(false); const [apiaryId, setApiaryId] = useState(""); const [error, setError] = useState(null); + const [focusError, setFocusError] = useState(false); + const [canReload, setCanReload] = useState(false); const { data: apiaries } = useQuery({ queryKey: ["apiaries", { status: "ACTIVE" }], queryFn: () => fetchApiaries({ status: "ACTIVE" }), }); const targets = (apiaries ?? []).filter((apiary) => apiary.id !== colony.apiaryId); + // Source and target: moving out of or into a restricted zone is blocked. + const guard = useRestrictionGuard(apiaryId ? [colony.apiaryId, apiaryId] : [colony.apiaryId]); const mutation = useMutation({ - mutationFn: () => moveColony(colony.id, { apiaryId }), + mutationFn: () => moveColony(colony.id, { apiaryId, ...guard.overrideFields() }), onSuccess: () => { invalidateColony(queryClient, colony.id); const target = targets.find((apiary) => apiary.id === apiaryId); @@ -87,12 +122,23 @@ function MoveDialog({ colony }: { colony: Colony }) { ); setOpen(false); }, - onError: (mutationError) => - setError(errorMessage(mutationError, "Volk konnte nicht umgesetzt werden.")), + onError: (mutationError) => { + const explained = guard.explain(mutationError, "Volk konnte nicht umgesetzt werden."); + setError(explained.text); + setFocusError(explained.focus); + setCanReload(explained.reload); + }, }); return ( - + { + setOpen(next); + if (next) guard.refresh(); + else guard.reset(); + }} + > @@ -108,10 +154,12 @@ function MoveDialog({ colony }: { colony: Colony }) { festgehalten. +
{ event.preventDefault(); + if (!guard.canProceed) return; setError(null); mutation.mutate(); }} @@ -128,8 +176,19 @@ function MoveDialog({ colony }: { colony: Colony }) { /> )} - - @@ -374,14 +433,21 @@ function MergeDialog({ colony }: { colony: Colony }) { const [open, setOpen] = useState(false); const [mergedColonyId, setMergedColonyId] = useState(""); const [error, setError] = useState(null); + const [focusError, setFocusError] = useState(false); + const [canReload, setCanReload] = useState(false); const { data: colonies } = useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() }); const merged = (colonies ?? []).find( (candidate) => candidate.id === mergedColonyId && candidate.id !== colony.id && isLivingColony(candidate), ); + // Merging inside one apiary leaves no apiary; only a merge across apiaries is blocked. + const guard = useRestrictionGuard( + merged && merged.apiaryId !== colony.apiaryId ? [colony.apiaryId, merged.apiaryId] : [], + ); const mutation = useMutation({ - mutationFn: () => mergeColony(colony.id, { mergedColonyIds: [mergedColonyId] }), + mutationFn: () => + mergeColony(colony.id, { mergedColonyIds: [mergedColonyId], ...guard.overrideFields() }), onSuccess: () => { invalidateColony(queryClient, colony.id); void queryClient.invalidateQueries({ queryKey: ["colonies"] }); @@ -390,12 +456,23 @@ function MergeDialog({ colony }: { colony: Colony }) { ); setOpen(false); }, - onError: (mutationError) => - setError(errorMessage(mutationError, "Völker konnten nicht vereinigt werden.")), + onError: (mutationError) => { + const explained = guard.explain(mutationError, "Völker konnten nicht vereinigt werden."); + setError(explained.text); + setFocusError(explained.focus); + setCanReload(explained.reload); + }, }); return ( - + { + setOpen(next); + if (next) guard.refresh(); + else guard.reset(); + }} + > @@ -422,12 +499,19 @@ function MergeDialog({ colony }: { colony: Colony }) { setError(null); }} /> - + + reloadColony(queryClient, colony.id, () => setError(null)) : undefined} + > ({ updateColony: vi.fn() })); @@ -12,6 +13,14 @@ vi.mock("@/lib/apiaries/api", () => ({ fetchApiaries: vi.fn().mockResolvedValue([{ id: "apiary-1", name: "Obstwiese" }]), })); vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); +vi.mock("@/lib/compliance/restrictions-api", async (importOriginal) => ({ + ...(await importOriginal()), + fetchActiveRestrictions: vi.fn().mockResolvedValue([]), +})); +let permissions: string[] = []; +vi.mock("@/lib/auth/auth-context", () => ({ + useAuth: () => ({ hasPermission: (key: string) => permissions.includes(key) }), +})); const colony: Colony = { id: "colony-1", @@ -245,3 +254,76 @@ describe("ColonyEditCard – legacy strength after clearing", () => { expect(screen.queryByText("Wird geladen…")).not.toBeInTheDocument(); }); }); + +const REASON = "Amtstierarzt hat die Standkorrektur erlaubt."; + +describe("ColonyEditCard – Sperrbezirk bei der Standkorrektur", () => { + beforeEach(() => { + permissions = []; + vi.mocked(updateColony).mockReset(); + vi.mocked(updateColony).mockResolvedValue(colony); + vi.mocked(fetchApiaries).mockResolvedValue([ + { id: "apiary-1", name: "Obstwiese" }, + { id: "apiary-2", name: "Hausgarten" }, + ] as never); + vi.mocked(fetchActiveRestrictions).mockResolvedValue([ + { + id: "r-2", + apiaryId: "apiary-2", + apiaryName: "Hausgarten", + disease: "Amerikanische Faulbrut", + startsOn: "2026-10-01", + }, + ]); + }); + + async function pickHausgarten() { + renderCard(); + await choose(/Bienenstand/, "Hausgarten"); + } + + it("shows the notice and disables Speichern when the corrected apiary is restricted", async () => { + await pickHausgarten(); + + expect( + await screen.findByText( + "Stand Hausgarten liegt im Sperrbezirk (Amerikanische Faulbrut, seit 01.10.2026). Standwechsel ist gesperrt.", + ), + ).toBeInTheDocument(); + const save = screen.getByRole("button", { name: "Speichern" }); + expect(save).toHaveAttribute("aria-disabled", "true"); + expect(save.getAttribute("aria-describedby")).toBeTruthy(); + }); + + it("saves other fields without notice while the apiary stays where it is", async () => { + renderCard({ ...colony, apiaryId: "apiary-2" }); + await userEvent.type(screen.getByLabelText("Beutentyp"), "x"); + + await waitFor(() => expect(screen.getByRole("button", { name: "Speichern" })).not.toHaveAttribute("aria-disabled")); + expect(screen.queryByText(/liegt im Sperrbezirk/)).not.toBeInTheDocument(); + }); + + it("sends the override reason with the correction after the confirmation", async () => { + permissions = ["compliance:manage"]; + await pickHausgarten(); + + await userEvent.click(await screen.findByRole("button", { name: /Ausnahme mit Begründung/ })); + await userEvent.type(screen.getByLabelText(/Begründung der Ausnahme/), REASON); + await userEvent.click(screen.getByRole("button", { name: "Ausnahme nutzen" })); + await userEvent.click(await screen.findByRole("button", { name: "Ausnahme bestätigen" })); + await waitFor(() => expect(screen.getByRole("button", { name: "Speichern" })).not.toHaveAttribute("aria-disabled")); + await userEvent.click(screen.getByRole("button", { name: "Speichern" })); + + expect(vi.mocked(updateColony).mock.calls[0][1]).toMatchObject({ + apiaryId: "apiary-2", + restrictionOverrideReason: REASON, + }); + }); + + it("does not send a reason when nothing is restricted", async () => { + renderCard(); + await userEvent.click(screen.getByRole("button", { name: "Speichern" })); + + expect(vi.mocked(updateColony).mock.calls[0][1]).not.toHaveProperty("restrictionOverrideReason"); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-edit-card.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-edit-card.tsx index 6aba25e..2a0d67c 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-edit-card.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-edit-card.tsx @@ -18,7 +18,6 @@ import { COLONY_TYPE_LABELS, MAX_OCCUPIED_FRAMES, } from "@/lib/colonies/labels"; -import { errorMessage } from "@/lib/forms/error-message"; import { emptyToNull } from "@/lib/forms/empty-to-null"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -28,6 +27,9 @@ import { Textarea } from "@/components/ui/textarea"; import { RecordSelect } from "@/components/form/record-select"; import { EnumSelect } from "@/components/form/enum-select"; import { NumberStepper } from "@/components/form/number-stepper"; +import { RestrictionGuardPanel } from "@/components/compliance/restriction-guard"; +import { BLOCKED_TEXT } from "@/components/compliance/restriction-notice"; +import { useFocusWhen, useRestrictionGuard } from "@/lib/compliance/use-restriction-guard"; import { Card, CardContent, @@ -88,6 +90,13 @@ export function ColonyEditCard({ colony }: { colony: Colony }) { colony.healthStatus, ); const [notes, setNotes] = useState(colony.notes ?? ""); + const [conflictText, setConflictText] = useState(null); + const [canReload, setCanReload] = useState(false); + const conflictRef = useFocusWhen(conflictText !== null); + // Only a correction of the apiary is blocked; other fields stay editable. + const guard = useRestrictionGuard( + apiaryId !== colony.apiaryId ? [colony.apiaryId, apiaryId] : [], + ); const apiariesQuery = useQuery({ queryKey: ["apiaries"], @@ -119,6 +128,7 @@ export function ColonyEditCard({ colony }: { colony: Colony }) { feedStatus, healthStatus, notes: emptyToNull(notes), + ...guard.overrideFields(), }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["colonies"] }); @@ -126,12 +136,19 @@ export function ColonyEditCard({ colony }: { colony: Colony }) { void queryClient.invalidateQueries({ queryKey: ["apiaries"] }); toast.success("Volk gespeichert"); }, - onError: (error) => - toast.error(errorMessage(error, "Volk konnte nicht gespeichert werden.")), + onError: (error) => { + const explained = guard.explain(error, "Volk konnte nicht gespeichert werden."); + // A 409 (restricted zone, apiary changed meanwhile) stays on the page, focused. + setCanReload(explained.reload); + if (explained.focus) setConflictText(explained.text); + else toast.error(explained.text); + }, }); function handleSubmit(event: FormEvent) { event.preventDefault(); + if (!guard.canProceed) return; + setConflictText(null); updateMutation.mutate(); } @@ -260,7 +277,38 @@ export function ColonyEditCard({ colony }: { colony: Colony }) { onChange={(event) => setNotes(event.target.value)} /> - + ) : null} + diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-treatments.spec.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-treatments.spec.tsx new file mode 100644 index 0000000..7338a91 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-treatments.spec.tsx @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ColonyTreatmentsCard } from "./colony-treatments"; +import { fetchColonyTreatments } from "@/lib/treatments/api"; +import type { Treatment } from "@/lib/treatments/types"; + +vi.mock("@/lib/treatments/api", () => ({ fetchColonyTreatments: vi.fn() })); +vi.mock("./treatment-edit-dialog", () => ({ + TreatmentEditDialog: ({ treatment, defaultOpen }: { treatment: Treatment; defaultOpen?: boolean }) => ( +

+ Dialog {treatment.id} {defaultOpen ? "offen" : "zu"} +

+ ), +})); + +function treatment(id: string): Treatment { + return { + id, + colonyId: "colony-1", + userId: "u-1", + date: "2026-08-01T00:00:00.000Z", + startDate: null, + endDate: null, + product: `Mittel ${id}`, + activeIngredient: null, + amount: null, + method: null, + reason: null, + batchNumber: null, + notes: null, + withdrawalPeriodDays: null, + supplier: null, + purchaseProof: null, + prescribingVet: null, + medicineId: null, + medicineLotId: null, + doseQuantity: null, + createdAt: "2026-08-01T00:00:00.000Z", + }; +} + +function renderCard(editTreatmentId?: string) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + +describe("ColonyTreatmentsCard", () => { + beforeEach(() => { + vi.mocked(fetchColonyTreatments).mockReset().mockResolvedValue([treatment("t-1"), treatment("t-2")]); + }); + + it("opens the edit dialog only for the treatment named by ?editTreatment=", async () => { + renderCard("t-2"); + + expect(await screen.findByText("Dialog t-2 offen")).toBeInTheDocument(); + expect(screen.getByText("Dialog t-1 zu")).toBeInTheDocument(); + }); + + it("opens no dialog without the parameter or for an unknown id", async () => { + renderCard("t-unknown"); + + expect(await screen.findByText("Dialog t-1 zu")).toBeInTheDocument(); + expect(screen.queryByText(/offen/)).not.toBeInTheDocument(); + }); + + it("says so when ?editTreatment names a treatment that is not in the list", async () => { + renderCard("t-unknown"); + + expect(await screen.findByText("Behandlung nicht gefunden.")).toBeInTheDocument(); + }); + + it("says nothing while the list is loading or the treatment exists", async () => { + renderCard("t-2"); + + await screen.findByText("Dialog t-2 offen"); + expect(screen.queryByText("Behandlung nicht gefunden.")).not.toBeInTheDocument(); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-treatments.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-treatments.tsx index 1bca6ca..643e9d9 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-treatments.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-treatments.tsx @@ -10,7 +10,14 @@ import { QueryState } from "@/components/query-state"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { TreatmentEditDialog } from "./treatment-edit-dialog"; -export function ColonyTreatmentsCard({ colonyId }: { colonyId: string }) { +export function ColonyTreatmentsCard({ + colonyId, + editTreatmentId, +}: { + colonyId: string; + /** The treatment whose edit dialog opens with the page (`?editTreatment=`). */ + editTreatmentId?: string; +}) { const treatmentsQuery = useQuery({ queryKey: ["colonies", colonyId, "treatments"], queryFn: () => fetchColonyTreatments(colonyId), @@ -30,6 +37,11 @@ export function ColonyTreatmentsCard({ colonyId }: { colonyId: string }) { +
+ {editTreatmentId && treatmentsQuery.data && !treatmentsQuery.data.some((t) => t.id === editTreatmentId) ? ( +

Behandlung nicht gefunden.

+ ) : null} +
{new Date(treatment.date).toLocaleDateString("de-DE")} · {treatment.product}

- +

{treatment.amount ?? "—"} diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/page.spec.tsx index bb82901..c42c7a6 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/page.spec.tsx @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; @@ -6,8 +6,10 @@ import ColonyDetailPage from "./page"; import { fetchColony } from "@/lib/colonies/api"; import type { Colony } from "@/lib/colonies/types"; +const search = vi.hoisted(() => ({ query: "" })); vi.mock("next/navigation", () => ({ useParams: () => ({ id: "colony-1" }), + useSearchParams: () => new URLSearchParams(search.query), })); vi.mock("@/lib/colonies/api", () => ({ fetchColony: vi.fn() })); vi.mock("@/lib/export/api", () => ({ @@ -21,7 +23,11 @@ vi.mock("./colony-history", () => ({ ColonyHistoryCard: () => null })); vi.mock("./colony-inspections", () => ({ ColonyInspectionsCard: () => null })); vi.mock("./colony-varroa", () => ({ ColonyVarroaCard: () => null })); vi.mock("./colony-swarm-control", () => ({ ColonySwarmControlCard: () => null })); -vi.mock("./colony-treatments", () => ({ ColonyTreatmentsCard: () => null })); +vi.mock("./colony-treatments", () => ({ + ColonyTreatmentsCard: ({ editTreatmentId }: { editTreatmentId?: string }) => ( +

Behandlungen, Dialog für {editTreatmentId ?? "keine"}

+ ), +})); vi.mock("./colony-feedings", () => ({ ColonyFeedingsCard: () => null })); vi.mock("@/components/document-upload-card", () => ({ DocumentUploadCard: () => null })); vi.mock("@/components/autopilot/next-steps-card", () => ({ @@ -121,3 +127,35 @@ describe("ColonyDetailPage – Als Nächstes", () => { ).toBeTruthy(); }); }); + +describe("ColonyDetailPage – ?editTreatment", () => { + const colony = { + id: "colony-1", + code: "V-01", + colonyType: "PRODUCTION_COLONY", + status: "ACTIVE", + strength: null, + occupiedFrames: null, + } as Colony; + + beforeEach(() => { + vi.mocked(fetchColony).mockReset().mockResolvedValue(colony); + }); + + afterEach(() => { + search.query = ""; + }); + + it("the colony page opens the edit dialog for ?editTreatment=", async () => { + search.query = "editTreatment=t-7"; + renderPage(); + + expect(await screen.findByText("Behandlungen, Dialog für t-7")).toBeInTheDocument(); + }); + + it("opens no dialog without the parameter", async () => { + renderPage(); + + expect(await screen.findByText("Behandlungen, Dialog für keine")).toBeInTheDocument(); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/page.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/page.tsx index 93c667f..ddcc4d3 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/page.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { useParams } from "next/navigation"; +import { useParams, useSearchParams } from "next/navigation"; import { useMutation, useQuery } from "@tanstack/react-query"; import { ClipboardCheck, FileText, Layers } from "lucide-react"; import { toast } from "sonner"; @@ -27,6 +27,8 @@ const BACK = { backHref: "/colonies", backLabel: "Alle Völker" }; export default function ColonyDetailPage() { const params = useParams<{ id: string }>(); + // A link from the compliance check opens the edit dialog of one treatment. + const editTreatmentId = useSearchParams().get("editTreatment") ?? undefined; const colonyQuery = useQuery({ queryKey: ["colonies", params.id], queryFn: () => fetchColony(params.id), @@ -99,7 +101,7 @@ export default function ColonyDetailPage() { - + diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/treatment-edit-dialog.spec.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/treatment-edit-dialog.spec.tsx new file mode 100644 index 0000000..6c8c9b6 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/treatment-edit-dialog.spec.tsx @@ -0,0 +1,281 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { TreatmentEditDialog } from "./treatment-edit-dialog"; +import { updateTreatment } from "@/lib/treatments/api"; +import type { Treatment } from "@/lib/treatments/types"; +import { fetchColonies } from "@/lib/colonies/api"; +import { fetchMedicines, fetchSelectableLots } from "@/lib/medicines/api"; +import { lot, medicine } from "@/lib/medicines/fixtures.spec-helper"; +import { ApiError } from "@/lib/auth/api-client"; + +const granted = vi.hoisted(() => new Set()); +vi.mock("@/lib/auth/auth-context", () => ({ + useCurrentUserId: () => "user-1", + useSessionMode: () => "online", + useAuth: () => ({ hasPermission: (permission: string) => granted.has(permission) }), +})); +vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() })); +vi.mock("@/lib/treatments/api", () => ({ updateTreatment: vi.fn() })); +vi.mock("@/lib/medicines/api", () => ({ fetchMedicines: vi.fn(), fetchSelectableLots: vi.fn() })); + +const OXUVAR = medicine({ id: "m-oxuvar", name: "Oxuvar", unit: "ML", withdrawalPeriodDays: 0 }); +const APIVAR = medicine({ id: "m-apivar", name: "Apivar", unit: "PIECE", withdrawalPeriodDays: 21 }); +const LOT = lot({ + id: "l-old", + medicineId: "m-oxuvar", + unit: "ML", + batchNumber: "4711", + remaining: 0, + expiresOn: "2027-03-31", + supplier: "Tierarzt Muster", +}); + +const FREE_TEXT: Treatment = { + id: "t-1", + colonyId: "colony-1", + userId: "u-1", + date: "2026-08-01T00:00:00.000Z", + startDate: null, + endDate: null, + product: "Oxuvar", + activeIngredient: "Oxalsäure", + amount: "50 ml", + method: null, + reason: null, + batchNumber: "Ch-1", + notes: "alt", + withdrawalPeriodDays: 0, + supplier: "Imkereibedarf Muster", + purchaseProof: null, + prescribingVet: null, + medicineId: null, + medicineLotId: null, + doseQuantity: null, + createdAt: "2026-08-01T00:00:00.000Z", +}; + +const FROM_PACK: Treatment = { + ...FREE_TEXT, + id: "t-2", + batchNumber: null, + supplier: null, + amount: null, + medicineId: "m-oxuvar", + medicineLotId: "l-old", + doseQuantity: 30, +}; + +async function openDialog(treatment: Treatment) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); + await userEvent.click(screen.getByRole("button", { name: "Bearbeiten" })); + return screen.findByRole("dialog"); +} + +describe("TreatmentEditDialog", () => { + beforeEach(() => { + granted.clear(); + granted.add("veterinary-medicines:read"); + vi.mocked(fetchColonies).mockResolvedValue([ + { id: "colony-1", code: "V-01", status: "ACTIVE" }, + ] as never); + vi.mocked(fetchMedicines).mockReset().mockResolvedValue([OXUVAR]); + vi.mocked(fetchSelectableLots).mockReset().mockResolvedValue([LOT]); + vi.mocked(updateTreatment).mockReset().mockResolvedValue(FREE_TEXT); + }); + + it("opens straight away with defaultOpen", async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); + + expect(await screen.findByRole("dialog")).toBeInTheDocument(); + expect(screen.getByText("Behandlung bearbeiten")).toBeInTheDocument(); + }); + + it("shows the pack of the treatment, asks for it with includeLotId and hides the lot-sourced fields", async () => { + const dialog = await openDialog(FROM_PACK); + + await waitFor(() => expect(fetchSelectableLots).toHaveBeenCalledWith("m-oxuvar", "l-old")); + expect(await screen.findByText(/^aus Packung: Ch\.-B\. 4711/)).toBeInTheDocument(); + expect(dialog).toHaveTextContent("Rest 0 ml"); + expect(screen.getByLabelText(/Dosis/)).toHaveValue("30"); + expect(screen.queryByLabelText(/Lieferant/)).not.toBeInTheDocument(); + expect(screen.queryByLabelText(/Chargennummer/)).not.toBeInTheDocument(); + }); + + it("a notes-only edit sends none of the medicine fields", async () => { + await openDialog(FROM_PACK); + await screen.findByText(/^aus Packung/); + await userEvent.type(screen.getByLabelText(/^Notizen/), " neu"); + + await userEvent.click(screen.getByRole("button", { name: "Speichern" })); + + await waitFor(() => expect(updateTreatment).toHaveBeenCalledTimes(1)); + const [, patch] = vi.mocked(updateTreatment).mock.calls[0]; + expect(patch).toMatchObject({ notes: "alt neu" }); + for (const omitted of [ + "product", + "medicineId", + "medicineLotId", + "doseQuantity", + "supplier", + "purchaseProof", + "batchNumber", + "prescribingVet", + ]) { + expect(patch).not.toHaveProperty(omitted); + } + }); + + it("a changed dose sends medicine, pack and dose and clears the lot-sourced texts with null", async () => { + await openDialog(FROM_PACK); + await screen.findByText(/^aus Packung/); + await userEvent.clear(screen.getByLabelText(/Dosis/)); + await userEvent.type(screen.getByLabelText(/Dosis/), "2,5"); + + await userEvent.click(screen.getByRole("button", { name: "Speichern" })); + + await waitFor(() => expect(updateTreatment).toHaveBeenCalledTimes(1)); + const [id, patch] = vi.mocked(updateTreatment).mock.calls[0]; + expect(id).toBe("t-2"); + expect(patch).toMatchObject({ + medicineId: "m-oxuvar", + medicineLotId: "l-old", + doseQuantity: 2.5, + supplier: null, + purchaseProof: null, + batchNumber: null, + prescribingVet: null, + }); + expect(patch).not.toHaveProperty("product"); + }); + + it("choosing „Ohne Packung“ sends medicineLotId null and keeps the catalogue medicine", async () => { + await openDialog(FROM_PACK); + await screen.findByText(/^aus Packung/); + await userEvent.click(screen.getByRole("combobox", { name: /Packung/ })); + await userEvent.click(await screen.findByRole("option", { name: /Ohne Packung/ })); + + await userEvent.click(screen.getByRole("button", { name: "Speichern" })); + + await waitFor(() => expect(updateTreatment).toHaveBeenCalledTimes(1)); + expect(vi.mocked(updateTreatment).mock.calls[0][1]).toMatchObject({ + medicineId: "m-oxuvar", + medicineLotId: null, + doseQuantity: 30, + }); + }); + + it("switching a catalogue treatment to „Anderes Mittel“ clears medicine, pack and dose", async () => { + await openDialog(FROM_PACK); + await screen.findByText(/^aus Packung/); + await userEvent.click(screen.getByRole("combobox", { name: /Aus dem Katalog/ })); + await userEvent.click(await screen.findByRole("option", { name: "Anderes Mittel" })); + await userEvent.type(screen.getByLabelText(/^Mittel/), "Selbstgemischt"); + + await userEvent.click(screen.getByRole("button", { name: "Speichern" })); + + await waitFor(() => expect(updateTreatment).toHaveBeenCalledTimes(1)); + expect(vi.mocked(updateTreatment).mock.calls[0][1]).toMatchObject({ + product: "Selbstgemischt", + medicineId: null, + medicineLotId: null, + doseQuantity: null, + }); + }); + + it("a free-text treatment edits its texts as before and sends them with the medicine group", async () => { + await openDialog(FREE_TEXT); + await userEvent.clear(screen.getByLabelText(/Lieferant/)); + + await userEvent.click(screen.getByRole("button", { name: "Speichern" })); + + await waitFor(() => expect(updateTreatment).toHaveBeenCalledTimes(1)); + expect(vi.mocked(updateTreatment).mock.calls[0][1]).toMatchObject({ + product: "Oxuvar", + supplier: null, + batchNumber: "Ch-1", + }); + }); + + it("shows the server's reason in German at the form", async () => { + vi.mocked(updateTreatment).mockRejectedValue( + new ApiError(400, "Diese Angabe kommt aus der Packung", { + message: "Diese Angabe kommt aus der Packung", + }), + ); + await openDialog(FREE_TEXT); + await userEvent.clear(screen.getByLabelText(/Lieferant/)); + + await userEvent.click(screen.getByRole("button", { name: "Speichern" })); + + expect(await screen.findByRole("alert")).toHaveTextContent("Diese Angabe kommt aus der Packung"); + }); + + describe("switching the medicine", () => { + beforeEach(() => { + vi.mocked(fetchMedicines).mockResolvedValue([OXUVAR, APIVAR]); + }); + + async function switchToApivar() { + await openDialog({ ...FROM_PACK, withdrawalPeriodDays: 7 }); + await screen.findByText(/^aus Packung/); + await userEvent.click(screen.getByRole("combobox", { name: /Aus dem Katalog/ })); + await userEvent.click(await screen.findByRole("option", { name: "Apivar" })); + } + + it("keeps the old withdrawal period, warns, and takes the catalogue value only on request", async () => { + await switchToApivar(); + + expect(screen.getByLabelText(/Wartezeit/)).toHaveValue(7); + expect( + await screen.findByText("Wartezeit stammt vom vorherigen Mittel – bitte prüfen."), + ).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "aus Katalog übernehmen" })); + + expect(screen.getByLabelText(/Wartezeit/)).toHaveValue(21); + expect(screen.queryByText(/vom vorherigen Mittel/)).not.toBeInTheDocument(); + }); + + it("does not warn when the new medicine has the same withdrawal period", async () => { + await openDialog({ ...FROM_PACK, withdrawalPeriodDays: 21 }); + await screen.findByText(/^aus Packung/); + await userEvent.click(screen.getByRole("combobox", { name: /Aus dem Katalog/ })); + await userEvent.click(await screen.findByRole("option", { name: "Apivar" })); + + expect(screen.queryByText(/vom vorherigen Mittel/)).not.toBeInTheDocument(); + }); + + it("an edit of the field removes the warning", async () => { + await switchToApivar(); + await screen.findByText(/vom vorherigen Mittel/); + + await userEvent.type(screen.getByLabelText(/Wartezeit/), "0"); + + expect(screen.queryByText(/vom vorherigen Mittel/)).not.toBeInTheDocument(); + }); + + it("fills an empty withdrawal period with the note aus Katalog and never preselects a pack", async () => { + vi.mocked(fetchSelectableLots).mockResolvedValue([LOT]); + await openDialog({ ...FROM_PACK, withdrawalPeriodDays: null }); + await screen.findByText(/^aus Packung/); + await userEvent.click(screen.getByRole("combobox", { name: /Aus dem Katalog/ })); + await userEvent.click(await screen.findByRole("option", { name: "Apivar" })); + + expect(screen.getByLabelText(/Wartezeit/)).toHaveValue(21); + expect(screen.getAllByText("aus Katalog").length).toBeGreaterThan(0); + expect(screen.getByRole("combobox", { name: /Packung/ })).toHaveTextContent("Ohne Packung"); + }); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/treatment-edit-dialog.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/treatment-edit-dialog.tsx index b5c9969..907259f 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/treatment-edit-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/treatment-edit-dialog.tsx @@ -2,11 +2,25 @@ import { FormEvent, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useAuth } from "@/lib/auth/auth-context"; import { updateTreatment } from "@/lib/treatments/api"; import { invalidateVarroaBands } from "@/lib/varroa/use-varroa-bands"; import { Treatment } from "@/lib/treatments/types"; import { emptyToNull } from "@/lib/forms/empty-to-null"; -import { errorMessage } from "@/lib/forms/error-message"; +import { saveErrorText } from "@/lib/honey-batches/errors"; +import { catalogueValues, useCataloguePrefill } from "@/lib/treatments/use-catalogue-prefill"; +import { doseError, doseText } from "@/lib/treatments/dose"; +import { medicinePatch } from "@/lib/treatments/edit-patch"; +import { lotSourceLine } from "@/lib/treatments/lot-text"; +import { useMedicineChoice } from "@/lib/treatments/use-medicine-choice"; +import type { MedicineView } from "@/lib/medicines/types"; +import { AlertTriangle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + CatalogueNote, + MedicineLotFields, + describedByCatalogue, +} from "@/components/treatments/medicine-lot-fields"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { ColonySelectField } from "@/components/colony-select-field"; @@ -27,19 +41,41 @@ import { } from "./edit-dialog-parts"; import { toApiDate } from "@/lib/dates"; +interface CatalogueState { + activeIngredient: string; + withdrawalPeriodDays: string; + method: string; + doseQuantity: string; +} + interface FieldErrors { date?: string; product?: string; + doseQuantity?: string; } -export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) { +const SAVE_FAILED = "Behandlung konnte nicht gespeichert werden."; +const STALE_WITHDRAWAL = "Wartezeit stammt vom vorherigen Mittel – bitte prüfen."; + +export function TreatmentEditDialog({ + treatment, + defaultOpen = false, +}: { + treatment: Treatment; + /** Opened by a link (`?editTreatment=`). */ + defaultOpen?: boolean; +}) { const queryClient = useQueryClient(); - const [open, setOpen] = useState(false); + const { hasPermission } = useAuth(); + const [open, setOpen] = useState(defaultOpen); 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 [medicineId, setMedicineId] = useState(treatment.medicineId ?? ""); + const [medicineLotId, setMedicineLotId] = useState(treatment.medicineLotId ?? ""); + const [doseQuantity, setDoseQuantity] = useState(doseText(treatment.doseQuantity)); const [activeIngredient, setActiveIngredient] = useState(treatment.activeIngredient ?? ""); const [amount, setAmount] = useState(treatment.amount ?? ""); const [method, setMethod] = useState(treatment.method ?? ""); @@ -55,6 +91,63 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) { const [fieldErrors, setFieldErrors] = useState({}); const [submitError, setSubmitError] = useState(null); + const choice = useMedicineChoice({ + // Nothing is asked while the dialog is closed (every row has one). + canRead: open && hasPermission("veterinary-medicines:read"), + medicineId, + medicineLotId, + includeLotId: treatment.medicineLotId ?? undefined, + keepMedicineId: treatment.medicineId ?? undefined, + startDecided: true, + onPreselect: setMedicineLotId, + }); + const hasLot = medicineLotId !== ""; + + // The catalogue fields as one object, so the same prefill hook as in the create form works here. + const catalogue = useCataloguePrefill( + (update) => { + const current = { activeIngredient, withdrawalPeriodDays, method, doseQuantity }; + const next = typeof update === "function" ? update(current) : update; + setActiveIngredient(next.activeIngredient); + setWithdrawalPeriodDays(next.withdrawalPeriodDays); + setMethod(next.method); + setDoseQuantity(next.doseQuantity); + }, + () => undefined, + ); + // Set when a switch left a withdrawal period of the previous medicine in place + // (the value is the new catalogue value, or null when the catalogue has none). + const [staleWithdrawal, setStaleWithdrawal] = useState(undefined); + const [catalogueReady, setCatalogueReady] = useState(false); + if (open && !catalogueReady) { + // Whatever the treatment already holds belongs to the user: only empty fields are filled. + setCatalogueReady(true); + catalogue.resetFrom({ activeIngredient, withdrawalPeriodDays, method, doseQuantity }); + } + + /** + * A catalogue medicine (null: free text) fills the fields that are empty, with the note + * "aus Katalog". A withdrawal period that is already there is never replaced silently: if + * it differs from the new medicine it is flagged and can be taken over explicitly. + */ + function chooseMedicine(medicine: MedicineView | null) { + const hadMedicine = medicineId !== ""; + const oldWithdrawal = catalogue.filled.withdrawalPeriodDays ? "" : withdrawalPeriodDays.trim(); + const known = medicine ? catalogueValues(medicine).withdrawalPeriodDays : undefined; + setMedicineId(medicine?.id ?? ""); + setMedicineLotId(""); + setProduct(medicine ? medicine.name : medicineId ? "" : product); + setFieldErrors((current) => ({ ...current, product: undefined, doseQuantity: undefined })); + catalogue.apply(medicine); + const isStale = hadMedicine && oldWithdrawal !== "" && oldWithdrawal !== known; + setStaleWithdrawal(isStale ? (known ?? null) : undefined); + } + + function chooseLot(lotId: string) { + choice.decideLot(); + setMedicineLotId(lotId); + } + const mutation = useMutation({ mutationFn: () => updateTreatment(treatment.id, { @@ -62,17 +155,22 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) { date: toApiDate(date), startDate: emptyToNull(startDate, toApiDate), endDate: emptyToNull(endDate, toApiDate), - product, activeIngredient: emptyToNull(activeIngredient), amount: emptyToNull(amount), method: emptyToNull(method), reason: emptyToNull(reason), - batchNumber: emptyToNull(batchNumber), notes: emptyToNull(notes), withdrawalPeriodDays: emptyToNull(withdrawalPeriodDays, Number), - supplier: emptyToNull(supplier), - purchaseProof: emptyToNull(purchaseProof), - prescribingVet: emptyToNull(prescribingVet), + ...medicinePatch(treatment, { + product, + medicineId, + medicineLotId, + doseQuantity, + batchNumber, + supplier, + purchaseProof, + prescribingVet, + }), }), onSuccess: () => { // After re-filing, both the colony it left and the one it joined @@ -84,10 +182,14 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) { } // A treatment moves the post-treatment window of the varroa lights. void invalidateVarroaBands(queryClient); + // The server books the change of pack or dose: the rest moves. + if (treatment.medicineLotId || medicineLotId) { + void queryClient.invalidateQueries({ queryKey: ["medicine-lots"] }); + void queryClient.invalidateQueries({ queryKey: ["medicines"] }); + } setOpen(false); }, - onError: (error) => - setSubmitError(errorMessage(error, "Behandlung konnte nicht gespeichert werden.")), + onError: (error) => setSubmitError(saveErrorText(error, SAVE_FAILED)), }); function handleSubmit(event: FormEvent) { @@ -95,10 +197,11 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) { setSubmitError(null); const errors: FieldErrors = { date: date ? undefined : DATE_REQUIRED_MESSAGE, - product: product.trim() ? undefined : "Bitte das verwendete Mittel angeben.", + product: medicineId || product.trim() ? undefined : "Bitte das verwendete Mittel angeben.", + doseQuantity: medicineId ? (doseError(doseQuantity) ?? undefined) : undefined, }; setFieldErrors(errors); - if (errors.date || errors.product) { + if (errors.date || errors.product || errors.doseQuantity) { return; } mutation.mutate(); @@ -138,30 +241,48 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) { - - {({ id, describedBy, invalid }) => ( - { - setProduct(event.target.value); - setFieldErrors((current) => ({ ...current, product: undefined })); - }} - /> - )} - + { + setProduct(next); + setFieldErrors((current) => ({ ...current, product: undefined })); + }} + onMedicineChange={chooseMedicine} + onLotChange={chooseLot} + onDoseChange={(next) => { + catalogue.edited("doseQuantity"); + setDoseQuantity(next); + setFieldErrors((current) => ({ ...current, doseQuantity: undefined })); + }} + /> + {hasLot ? ( +

+ Änderungen an Packung oder Dosis werden gegengebucht. +

+ ) : null} {({ id }) => ( - setActiveIngredient(event.target.value)} - /> + <> + { + catalogue.edited("activeIngredient"); + setActiveIngredient(event.target.value); + }} + /> + + )} @@ -176,12 +297,19 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) { {({ id }) => ( - setMethod(event.target.value)} - /> + <> + { + catalogue.edited("method"); + setMethod(event.target.value); + }} + /> + + )} @@ -223,15 +351,44 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) { {({ id }) => ( - setWithdrawalPeriodDays(event.target.value)} - /> + <> + { + catalogue.edited("withdrawalPeriodDays"); + setStaleWithdrawal(undefined); + setWithdrawalPeriodDays(event.target.value); + }} + /> + +
+ {staleWithdrawal !== undefined ? ( +
+ + {STALE_WITHDRAWAL} + {staleWithdrawal !== null ? ( + + ) : null} +
+ ) : null} +
+ )}
@@ -241,49 +398,57 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) { title="Für das Bestandsbuch" description="Pflicht bei jedem Tierarzneimittel, auch bei freiverkäuflichen Säuren (Art. 108 VO (EU) 2019/6). Belege 5 Jahre aufbewahren." > - - {({ id }) => ( - setBatchNumber(event.target.value)} - /> - )} - - - {({ id }) => ( - setSupplier(event.target.value)} - /> - )} - - - {({ id }) => ( - setPurchaseProof(event.target.value)} - /> - )} - - - {({ id, describedBy }) => ( - setPrescribingVet(event.target.value)} - /> - )} - + {hasLot ? ( +

+ {choice.lot ? lotSourceLine(choice.lot) : "aus Packung"} +

+ ) : ( + <> + + {({ id }) => ( + setBatchNumber(event.target.value)} + /> + )} + + + {({ id }) => ( + setSupplier(event.target.value)} + /> + )} + + + {({ id }) => ( + setPurchaseProof(event.target.value)} + /> + )} + + + {({ id, describedBy }) => ( + setPrescribingVet(event.target.value)} + /> + )} + + + )} ({ @@ -13,6 +15,15 @@ vi.mock("next/navigation", () => ({ })); vi.mock("@/lib/colonies/api", () => ({ createColony: vi.fn() })); vi.mock("@/lib/apiaries/api", () => ({ fetchApiaries: vi.fn() })); +vi.mock("@/lib/compliance/restrictions-api", async (importOriginal) => ({ + ...(await importOriginal()), + fetchActiveRestrictions: vi.fn().mockResolvedValue([]), +})); +let permissions: string[] = []; +vi.mock("@/lib/auth/auth-context", async (importOriginal) => ({ + ...(await importOriginal()), + useAuth: () => ({ hasPermission: (key: string) => permissions.includes(key) }), +})); function renderPage() { const client = new QueryClient({ @@ -113,3 +124,81 @@ describe("NewColonyPage – besetzte Waben", () => { expect(field).toHaveValue("40"); }); }); + +describe("NewColonyPage – Sperrbezirk", () => { + const REASON = "Amtstierarzt hat das Anlegen erlaubt."; + + beforeEach(() => { + permissions = []; + window.localStorage.clear(); + push.mockReset(); + vi.mocked(fetchApiaries).mockResolvedValue([ + { id: "apiary-1", name: "Hausgarten", status: "ACTIVE" }, + ] as never); + vi.mocked(fetchActiveRestrictions).mockResolvedValue([ + { + id: "r-1", + apiaryId: "apiary-1", + apiaryName: "Hausgarten", + disease: "Amerikanische Faulbrut", + startsOn: "2026-10-01", + }, + ]); + vi.mocked(createColony).mockReset(); + vi.mocked(createColony).mockResolvedValue({ id: "colony-9" } as never); + }); + + async function fillRestricted() { + renderPage(); + await userEvent.type(screen.getByLabelText(/Code/), "V-09"); + await userEvent.click(screen.getByRole("combobox", { name: /Bienenstand/ })); + await userEvent.click(await screen.findByRole("option", { name: "Hausgarten" })); + } + + it("shows the same notice and disables Volk anlegen for a restricted apiary", async () => { + await fillRestricted(); + + expect( + await screen.findByText( + "Stand Hausgarten liegt im Sperrbezirk (Amerikanische Faulbrut, seit 01.10.2026). Anlegen ist gesperrt.", + ), + ).toBeInTheDocument(); + const submit = screen.getByRole("button", { name: "Volk anlegen" }); + expect(submit).toHaveAttribute("aria-disabled", "true"); + expect(submit.getAttribute("aria-describedby")).toBeTruthy(); + }); + + it("creates the colony with the override reason after the confirmation", async () => { + permissions = ["compliance:manage"]; + await fillRestricted(); + + await userEvent.click(await screen.findByRole("button", { name: /Ausnahme mit Begründung/ })); + await userEvent.type(screen.getByLabelText(/Begründung der Ausnahme/), REASON); + await userEvent.click(screen.getByRole("button", { name: "Ausnahme nutzen" })); + await userEvent.click(await screen.findByRole("button", { name: "Ausnahme bestätigen" })); + await waitFor(() => expect(screen.getByRole("button", { name: "Volk anlegen" })).not.toHaveAttribute("aria-disabled")); + await userEvent.click(screen.getByRole("button", { name: "Volk anlegen" })); + + expect(vi.mocked(createColony).mock.calls[0][0]).toMatchObject({ + code: "V-09", + apiaryId: "apiary-1", + restrictionOverrideReason: REASON, + }); + }); + + it("puts the focus on the server's RESTRICTED_ZONE message", async () => { + vi.mocked(fetchActiveRestrictions).mockResolvedValue([]); + vi.mocked(createColony).mockRejectedValue( + new ApiError(409, "Der Bienenstand „Hausgarten“ liegt im Sperrbezirk.", { + code: "RESTRICTED_ZONE", + restrictionIds: ["r-1"], + }), + ); + await fillRestricted(); + await userEvent.click(screen.getByRole("button", { name: "Volk anlegen" })); + + const alert = await screen.findByRole("alert"); + expect(alert).toHaveTextContent("liegt im Sperrbezirk"); + await waitFor(() => expect(alert).toHaveFocus()); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/colonies/new/page.tsx b/ApiCore-Frontend/src/app/(app)/colonies/new/page.tsx index 0b99266..5dccc4f 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/new/page.tsx @@ -9,7 +9,6 @@ import { createColony } from "@/lib/colonies/api"; import { COLONY_TYPES, COLONY_TYPE_LABELS, MAX_OCCUPIED_FRAMES } from "@/lib/colonies/labels"; import { fetchApiaries } from "@/lib/apiaries/api"; import { useFormDraft } from "@/lib/forms/use-form-draft"; -import { errorMessage } from "@/lib/forms/error-message"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { EnumSelect } from "@/components/form/enum-select"; @@ -22,6 +21,9 @@ import { Field } from "@/components/form/field"; import { FormSection } from "@/components/form/form-section"; import { StickySubmit } from "@/components/form/sticky-submit"; import { DraftBanner } from "@/components/form/draft-banner"; +import { RestrictionGuardPanel } from "@/components/compliance/restriction-guard"; +import { BLOCKED_TEXT } from "@/components/compliance/restriction-notice"; +import { useFocusWhen, useRestrictionGuard } from "@/lib/compliance/use-restriction-guard"; import { ColonyFormErrors, ColonyFormValues, @@ -42,6 +44,9 @@ export default function NewColonyPage() { const [dirty, setDirty] = useState(false); const [errors, setErrors] = useState({}); const [submitError, setSubmitError] = useState(null); + const [focusError, setFocusError] = useState(false); + const errorRef = useFocusWhen(submitError !== null && focusError); + const guard = useRestrictionGuard(values.apiaryId ? [values.apiaryId] : []); const draft = useFormDraft("colony-new", values, dirty); function set( @@ -54,7 +59,8 @@ export default function NewColonyPage() { } const mutation = useMutation({ - mutationFn: createColony, + mutationFn: (input: ReturnType) => + createColony({ ...input, ...guard.overrideFields() }), onSuccess: (colony) => { draft.clear(); void queryClient.invalidateQueries({ queryKey: ["colonies"] }); @@ -62,14 +68,15 @@ export default function NewColonyPage() { router.push(`/colonies/${colony.id}`); }, onError: (mutationError) => { - setSubmitError( - errorMessage(mutationError, "Volk konnte nicht angelegt werden."), - ); + const explained = guard.explain(mutationError, "Volk konnte nicht angelegt werden."); + setSubmitError(explained.text); + setFocusError(explained.focus); }, }); function handleSubmit(event: FormEvent) { event.preventDefault(); + if (!guard.canProceed) return; setSubmitError(null); const found = validateColony(values); setErrors(found); @@ -223,12 +230,16 @@ export default function NewColonyPage() { + + {submitError}

@@ -239,6 +250,8 @@ export default function NewColonyPage() { type="submit" className="h-12 text-base" disabled={mutation.isPending} + aria-disabled={guard.canProceed ? undefined : true} + aria-describedby={guard.canProceed ? undefined : guard.noticeId} > {mutation.isPending ? "Wird angelegt…" : "Volk anlegen"} diff --git a/ApiCore-Frontend/src/app/(app)/compliance/notices/[id]/mark-sent-dialog.tsx b/ApiCore-Frontend/src/app/(app)/compliance/notices/[id]/mark-sent-dialog.tsx new file mode 100644 index 0000000..e3cd86b --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/compliance/notices/[id]/mark-sent-dialog.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { FormEvent, useState } from "react"; +import { Send } from "lucide-react"; +import { useMutation } from "@tanstack/react-query"; +import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { berlinCalendarDay } from "@/lib/dates"; +import { complianceErrorText } from "@/lib/compliance/errors"; +import type { AuthorityNotice, MarkSentInput } from "@/lib/compliance/notice-types"; +import { noticeAction } from "@/lib/compliance/notices-api"; + +interface MarkSentDialogProps { + noticeId: string; + onDone: (notice: AuthorityNotice) => void; + /** Switched off from outside, e.g. offline; `describedBy` names the reason. */ + disabled?: boolean; + describedBy?: string; + /** The main action of the page (after the PDF was opened) or a secondary one before. */ + primary?: boolean; +} + +/** The Berlin calendar day as YYYY-MM-DD: the default for „Gesendet am“, whatever the device clock says. */ +function todayInput(): string { + return berlinCalendarDay(new Date().toISOString()); +} + +/** Id of the trigger, so the page can move the focus here once it becomes the main action. */ +export const MARK_SENT_TRIGGER_ID = "notice-mark-sent"; + +/** Step 3: „Als gesendet markieren“ asks for date, channel and reference. */ +export function MarkSentDialog({ noticeId, onDone, disabled = false, describedBy, primary = true }: MarkSentDialogProps) { + const [open, setOpen] = useState(false); + return ( + + + + Als gesendet markieren + + } + /> + + + Als gesendet markieren + + { + setOpen(false); + onDone(notice); + }} + /> + + + ); +} + +function MarkSentForm({ noticeId, onDone }: { noticeId: string; onDone: (notice: AuthorityNotice) => void }) { + const { isOffline, hintId, describedBy } = useOnlineOnly(); + const [sentOn, setSentOn] = useState(todayInput); + const [sentVia, setSentVia] = useState(""); + const [reference, setReference] = useState(""); + const [error, setError] = useState(null); + + const mutation = useMutation({ + mutationFn: () => { + const body: MarkSentInput = { + ...(sentOn ? { sentOn } : {}), + ...(sentVia.trim() ? { sentVia: sentVia.trim() } : {}), + ...(reference.trim() ? { reference: reference.trim() } : {}), + }; + return noticeAction(noticeId, "mark-sent", body); + }, + onSuccess: onDone, + onError: (mutationError) => + setError(complianceErrorText(mutationError, "Die Anzeige wurde nicht als gesendet markiert.")), + }); + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + setError(null); + if (isOffline) return; + mutation.mutate(); + } + + return ( +
+
+ + setSentOn(event.target.value)} /> +
+
+ + setSentVia(event.target.value)} + /> +
+
+ + setReference(event.target.value)} + /> +
+
+ {error ? ( +

+ {error} +

+ ) : null} +
+ + + + ); +} diff --git a/ApiCore-Frontend/src/app/(app)/compliance/notices/[id]/notice-edit-form.tsx b/ApiCore-Frontend/src/app/(app)/compliance/notices/[id]/notice-edit-form.tsx new file mode 100644 index 0000000..3a82d73 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/compliance/notices/[id]/notice-edit-form.tsx @@ -0,0 +1,209 @@ +"use client"; + +import { FormEvent, useState } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note"; +import { FormSection } from "@/components/form/form-section"; +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 { complianceErrorText } from "@/lib/compliance/errors"; +import { noticeDay } from "@/lib/compliance/notice-labels"; +import type { AuthorityNotice, NoticeEdit } from "@/lib/compliance/notice-types"; +import { updateNotice } from "@/lib/compliance/notices-api"; + +const COUNT_PROBLEM = "Bitte eine ganze Zahl ab 0 eingeben."; + +interface FormValues { + totalColonies: string; + referenceDate: string; + sentOn: string; + sentVia: string; + reference: string; + notes: string; +} + +function valuesOf(notice: AuthorityNotice): FormValues { + return { + totalColonies: String(notice.totalColonies), + referenceDate: noticeDay(notice.referenceDate) ?? "", + sentOn: noticeDay(notice.sentOn) ?? "", + sentVia: notice.sentVia ?? "", + reference: notice.reference ?? "", + notes: notice.notes ?? "", + }; +} + +/** Empty text means „remove“: the server stores null. */ +function textOrNull(value: string): string | null { + const trimmed = value.trim(); + return trimmed === "" ? null : trimmed; +} + +const DATE_PROBLEM = "Das Datum kann nicht entfernt werden."; + +interface Edit { + changes: Partial; + /** Messages per field name; shown at the field while they apply. */ + problems: Partial>; +} + +/** Only what the person changed; a day can be corrected, never removed. */ +function editOf(notice: AuthorityNotice, values: FormValues): Edit { + const before = valuesOf(notice); + const changes: Partial = {}; + const problems: Edit["problems"] = {}; + if (values.totalColonies !== before.totalColonies) { + const count = Number(values.totalColonies.trim()); + if (values.totalColonies.trim() === "" || !Number.isInteger(count) || count < 0) { + problems.totalColonies = COUNT_PROBLEM; + } else { + changes.totalColonies = count; + } + } + if (values.referenceDate === "") problems.referenceDate = DATE_PROBLEM; + else if (values.referenceDate !== before.referenceDate) changes.referenceDate = values.referenceDate; + if (notice.status === "SENT") { + if (values.sentOn === "") problems.sentOn = DATE_PROBLEM; + else if (values.sentOn !== before.sentOn) changes.sentOn = values.sentOn; + if (values.sentVia !== before.sentVia) changes.sentVia = textOrNull(values.sentVia); + if (values.reference !== before.reference) changes.reference = textOrNull(values.reference); + } + if (values.notes !== before.notes) changes.notes = textOrNull(values.notes); + return { changes, problems }; +} + +function FieldProblem({ id, text }: { id: string; text: string | undefined }) { + if (!text) return null; + return ( + + ); +} + +interface NoticeEditFormProps { + notice: AuthorityNotice; + onSaved: (notice: AuthorityNotice) => void; +} + +/** Corrections after the fact: the shown count, the day and the notes stay editable. */ +export function NoticeEditForm({ notice, onSaved }: NoticeEditFormProps) { + const { isOffline, hintId, describedBy } = useOnlineOnly(); + const [values, setValues] = useState(() => valuesOf(notice)); + const [error, setError] = useState(null); + const isSent = notice.status === "SENT"; + + const { changes, problems } = editOf(notice, values); + const hasProblems = Object.keys(problems).length > 0; + const hasChanges = Object.keys(changes).length > 0; + + const mutation = useMutation({ + mutationFn: (edit: Partial) => updateNotice(notice.id, edit), + onSuccess: onSaved, + onError: (mutationError) => setError(complianceErrorText(mutationError, "Die Änderungen wurden nicht gespeichert.")), + }); + + function set(key: K, value: FormValues[K]) { + setValues((current) => ({ ...current, [key]: value })); + } + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + setError(null); + if (hasProblems || isOffline || !hasChanges) return; + mutation.mutate(changes); + } + + return ( +
+ +
+ + set("totalColonies", event.target.value)} + /> + +
+
+ + set("referenceDate", event.target.value)} + /> + +
+ {isSent ? ( + <> +
+ + set("sentOn", event.target.value)} + /> + +
+
+ + set("sentVia", event.target.value)} + /> +
+
+ + set("reference", event.target.value)} + /> +
+ + ) : null} +
+ +