feat: T6 wave 6 – treatment form with medicine and lot, notices and settings UI, restricted zone UI
Backend Image / build-and-push (push) Successful in 6m52s
Frontend Image / build-and-push (push) Failing after 12m28s
Manifests / validate (push) Successful in 22s

- Treatment form and bulk entry: catalogue medicine or free text, lot with
  oldest preselected on create, lot fields from the lot, dose with at most
  3 decimals, catalogue prefill with explicit take-over of the withdrawal
  period on a medicine switch, ?editTreatment deep link.
- Authority notices: list with year filter, create with draft dedupe link,
  detail steps with PDF download and mark-sent, settings with MM-TT check,
  digit-rule editor and "Annahme – bitte prüfen" notes; COMPLIANCE label
  in notifications and Telegram categories.
- Restricted zone: page with active/planned/ended, create and end dialogs
  (endsOn inclusive), red band at the apiary, guards on move, merge, edit
  and new colony, override only with compliance:manage, harvest warning.
- ARCHITECTURE.md §12.11, Annahmen 91–94; TASKS.md T6 done with open items.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-15 17:33:55 +02:00
1 parent 13dda0e139
commit 8188e4cab3
90 files changed
+8069 -265

No files matched your search

+36 -4
View File
@@ -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. 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. 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. 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 ## 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. 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 ### 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. - **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`). - **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). - **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 ### 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) ### 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/`. **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-<Schlüssel>-<Subjekt>`). 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. - **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-<Schlüssel>-<Subjekt>`). 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. - **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“. - **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`). - **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). - **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. - **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`). - **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=<id>` 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.
@@ -2,12 +2,17 @@ import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fetchApiary } from "@/lib/apiaries/api"; import { fetchApiary } from "@/lib/apiaries/api";
import { fetchActiveRestrictions } from "@/lib/compliance/restrictions-api";
import ApiaryDetailPage from "./page"; import ApiaryDetailPage from "./page";
import { useAuth } from "@/lib/auth/auth-context"; import { useAuth } from "@/lib/auth/auth-context";
vi.mock("next/navigation", () => ({ useParams: () => ({ id: "a-1" }) })); vi.mock("next/navigation", () => ({ useParams: () => ({ id: "a-1" }) }));
vi.mock("@/lib/auth/auth-context", () => ({ useAuth: vi.fn() })); vi.mock("@/lib/auth/auth-context", () => ({ useAuth: vi.fn() }));
vi.mock("@/lib/apiaries/api", () => ({ fetchApiary: vi.fn() })); vi.mock("@/lib/apiaries/api", () => ({ fetchApiary: vi.fn() }));
vi.mock("@/lib/compliance/restrictions-api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/compliance/restrictions-api")>()),
fetchActiveRestrictions: vi.fn().mockResolvedValue([]),
}));
vi.mock("@/lib/export/api", () => ({ vi.mock("@/lib/export/api", () => ({
exportApiaryAnnualReportPdf: vi.fn(), exportApiaryAnnualReportPdf: vi.fn(),
exportTreatmentLogPdf: vi.fn(), exportTreatmentLogPdf: vi.fn(),
@@ -105,3 +110,33 @@ describe("ApiaryDetailPage", () => {
expect(screen.queryByText(/Das Bestandsbuch braucht das Recht/)).not.toBeInTheDocument(); 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();
});
});
@@ -9,6 +9,9 @@ import { useAuth } from "@/lib/auth/auth-context";
import { exportApiaryAnnualReportPdf, exportTreatmentLogPdf } from "@/lib/export/api"; import { exportApiaryAnnualReportPdf, exportTreatmentLogPdf } from "@/lib/export/api";
import { QrCodeDialog } from "@/components/qr-code-dialog"; import { QrCodeDialog } from "@/components/qr-code-dialog";
import { DocumentUploadCard } from "@/components/document-upload-card"; 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 { ApiarySeason } from "@/components/phenology/apiary-season";
import { DetailHeader, DetailQueryFallback, DetailStatus } from "@/components/detail-header"; import { DetailHeader, DetailQueryFallback, DetailStatus } from "@/components/detail-header";
import { ApiaryColoniesCard, StartRoundButton } from "./apiary-colonies-card"; import { ApiaryColoniesCard, StartRoundButton } from "./apiary-colonies-card";
@@ -26,6 +29,7 @@ export default function ApiaryDetailPage() {
queryFn: () => fetchApiary(params.id), queryFn: () => fetchApiary(params.id),
}); });
const apiary = apiaryQuery.data; const apiary = apiaryQuery.data;
const { restrictions } = useActiveRestrictions();
const exportReportMutation = useMutation({ const exportReportMutation = useMutation({
mutationFn: () => exportApiaryAnnualReportPdf(params.id, CURRENT_YEAR), mutationFn: () => exportApiaryAnnualReportPdf(params.id, CURRENT_YEAR),
@@ -84,6 +88,10 @@ export default function ApiaryDetailPage() {
]} ]}
{...BACK} {...BACK}
/> />
<RestrictionBand
restrictions={restrictionsAmong([apiary.id], restrictions)}
canOpenList={hasPermission("compliance:read")}
/>
{!canReadMedicines ? ( {!canReadMedicines ? (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Das Bestandsbuch braucht das Recht „Tierarzneimittel lesen“. Das Bestandsbuch braucht das Recht „Tierarzneimittel lesen“.
@@ -1,9 +1,11 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; 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 userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ColonyActions } from "./colony-actions"; 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 { ApiError } from "@/lib/auth/api-client";
import { todayLocalIsoDate } from "@/lib/dates"; import { todayLocalIsoDate } from "@/lib/dates";
import type { Colony, ColonyStatus } from "@/lib/colonies/types"; import type { Colony, ColonyStatus } from "@/lib/colonies/types";
@@ -22,6 +24,15 @@ vi.mock("@/lib/queens/api", () => ({
assignQueenColony: vi.fn(), assignQueenColony: vi.fn(),
})); }));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock("@/lib/compliance/restrictions-api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/compliance/restrictions-api")>()),
fetchActiveRestrictions: vi.fn().mockResolvedValue([]),
}));
let permissions: string[] = [];
vi.mock("@/lib/auth/auth-context", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/auth/auth-context")>()),
useAuth: () => ({ hasPermission: (key: string) => permissions.includes(key) }),
}));
function colony(id: string, code: string, status: ColonyStatus = "ACTIVE"): Colony { function colony(id: string, code: string, status: ColonyStatus = "ACTIVE"): Colony {
return { return {
@@ -247,3 +258,258 @@ describe("ColonyActions – split", () => {
expect(splitColony).not.toHaveBeenCalled(); 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();
});
});
@@ -36,6 +36,9 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { EnumSelect } from "@/components/form/enum-select"; import { EnumSelect } from "@/components/form/enum-select";
import { RecordSelect } from "@/components/form/record-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( function invalidateColony(
queryClient: ReturnType<typeof useQueryClient>, queryClient: ReturnType<typeof useQueryClient>,
@@ -46,21 +49,49 @@ function invalidateColony(
void queryClient.invalidateQueries({ queryKey: ["colonies", colonyId, "history"] }); void queryClient.invalidateQueries({ queryKey: ["colonies", colonyId, "history"] });
} }
/** "Neu laden" after a 409: the colony may have been moved meanwhile. */
function reloadColony(
queryClient: ReturnType<typeof useQueryClient>,
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. */ /** A failed action explains itself right above the button that caused it. */
function ActionFooter({ function ActionFooter({
error, error,
focusError = false,
onReload,
children, children,
}: { }: {
error: string | null; 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; children: ReactNode;
}) { }) {
const errorRef = useFocusWhen(error !== null && focusError);
return ( return (
<DialogFooter className="flex-col sm:flex-col sm:items-stretch"> <DialogFooter className="flex-col sm:flex-col sm:items-stretch">
{error ? ( {error ? (
<p role="alert" className="text-sm font-medium text-destructive"> <p
ref={errorRef}
role="alert"
tabIndex={-1}
className="text-sm font-medium text-destructive outline-none"
>
{error} {error}
</p> </p>
) : null} ) : null}
{error && onReload ? (
<Button type="button" variant="outline" size="touch" onClick={onReload}>
Neu laden
</Button>
) : null}
{children} {children}
</DialogFooter> </DialogFooter>
); );
@@ -71,14 +102,18 @@ function MoveDialog({ colony }: { colony: Colony }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [apiaryId, setApiaryId] = useState(""); const [apiaryId, setApiaryId] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [focusError, setFocusError] = useState(false);
const [canReload, setCanReload] = useState(false);
const { data: apiaries } = useQuery({ const { data: apiaries } = useQuery({
queryKey: ["apiaries", { status: "ACTIVE" }], queryKey: ["apiaries", { status: "ACTIVE" }],
queryFn: () => fetchApiaries({ status: "ACTIVE" }), queryFn: () => fetchApiaries({ status: "ACTIVE" }),
}); });
const targets = (apiaries ?? []).filter((apiary) => apiary.id !== colony.apiaryId); 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({ const mutation = useMutation({
mutationFn: () => moveColony(colony.id, { apiaryId }), mutationFn: () => moveColony(colony.id, { apiaryId, ...guard.overrideFields() }),
onSuccess: () => { onSuccess: () => {
invalidateColony(queryClient, colony.id); invalidateColony(queryClient, colony.id);
const target = targets.find((apiary) => apiary.id === apiaryId); const target = targets.find((apiary) => apiary.id === apiaryId);
@@ -87,12 +122,23 @@ function MoveDialog({ colony }: { colony: Colony }) {
); );
setOpen(false); setOpen(false);
}, },
onError: (mutationError) => onError: (mutationError) => {
setError(errorMessage(mutationError, "Volk konnte nicht umgesetzt werden.")), const explained = guard.explain(mutationError, "Volk konnte nicht umgesetzt werden.");
setError(explained.text);
setFocusError(explained.focus);
setCanReload(explained.reload);
},
}); });
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog
open={open}
onOpenChange={(next) => {
setOpen(next);
if (next) guard.refresh();
else guard.reset();
}}
>
<DialogTrigger <DialogTrigger
render={ render={
<Button variant="outline" className="min-h-11" disabled={!isLivingColony(colony)}> <Button variant="outline" className="min-h-11" disabled={!isLivingColony(colony)}>
@@ -108,10 +154,12 @@ function MoveDialog({ colony }: { colony: Colony }) {
festgehalten. festgehalten.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<MoveHint />
<form <form
className="flex flex-col gap-4" className="flex flex-col gap-4"
onSubmit={(event: FormEvent) => { onSubmit={(event: FormEvent) => {
event.preventDefault(); event.preventDefault();
if (!guard.canProceed) return;
setError(null); setError(null);
mutation.mutate(); mutation.mutate();
}} }}
@@ -128,8 +176,19 @@ function MoveDialog({ colony }: { colony: Colony }) {
/> />
)} )}
</Field> </Field>
<ActionFooter error={error}> <RestrictionGuardPanel guard={guard} blockedText={BLOCKED_TEXT.move} />
<Button type="submit" size="touch" disabled={!apiaryId || mutation.isPending}> <ActionFooter
error={error}
focusError={focusError}
onReload={canReload ? () => reloadColony(queryClient, colony.id, () => setError(null)) : undefined}
>
<Button
type="submit"
size="touch"
disabled={!apiaryId || mutation.isPending}
aria-disabled={guard.canProceed ? undefined : true}
aria-describedby={guard.canProceed ? undefined : guard.noticeId}
>
{mutation.isPending ? "Wird umgesetzt…" : "Umsetzen"} {mutation.isPending ? "Wird umgesetzt…" : "Umsetzen"}
</Button> </Button>
</ActionFooter> </ActionFooter>
@@ -374,14 +433,21 @@ function MergeDialog({ colony }: { colony: Colony }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [mergedColonyId, setMergedColonyId] = useState(""); const [mergedColonyId, setMergedColonyId] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [focusError, setFocusError] = useState(false);
const [canReload, setCanReload] = useState(false);
const { data: colonies } = useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() }); const { data: colonies } = useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() });
const merged = (colonies ?? []).find( const merged = (colonies ?? []).find(
(candidate) => (candidate) =>
candidate.id === mergedColonyId && candidate.id !== colony.id && isLivingColony(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({ const mutation = useMutation({
mutationFn: () => mergeColony(colony.id, { mergedColonyIds: [mergedColonyId] }), mutationFn: () =>
mergeColony(colony.id, { mergedColonyIds: [mergedColonyId], ...guard.overrideFields() }),
onSuccess: () => { onSuccess: () => {
invalidateColony(queryClient, colony.id); invalidateColony(queryClient, colony.id);
void queryClient.invalidateQueries({ queryKey: ["colonies"] }); void queryClient.invalidateQueries({ queryKey: ["colonies"] });
@@ -390,12 +456,23 @@ function MergeDialog({ colony }: { colony: Colony }) {
); );
setOpen(false); setOpen(false);
}, },
onError: (mutationError) => onError: (mutationError) => {
setError(errorMessage(mutationError, "Völker konnten nicht vereinigt werden.")), const explained = guard.explain(mutationError, "Völker konnten nicht vereinigt werden.");
setError(explained.text);
setFocusError(explained.focus);
setCanReload(explained.reload);
},
}); });
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog
open={open}
onOpenChange={(next) => {
setOpen(next);
if (next) guard.refresh();
else guard.reset();
}}
>
<DialogTrigger <DialogTrigger
render={ render={
<Button variant="outline" className="min-h-11" disabled={!isLivingColony(colony)}> <Button variant="outline" className="min-h-11" disabled={!isLivingColony(colony)}>
@@ -422,12 +499,19 @@ function MergeDialog({ colony }: { colony: Colony }) {
setError(null); setError(null);
}} }}
/> />
<ActionFooter error={error}> <RestrictionGuardPanel guard={guard} blockedText={BLOCKED_TEXT.merge} />
<ActionFooter
error={error}
focusError={focusError}
onReload={canReload ? () => reloadColony(queryClient, colony.id, () => setError(null)) : undefined}
>
<ConfirmDialog <ConfirmDialog
triggerLabel={mutation.isPending ? "Wird vereinigt…" : "Vereinigen"} triggerLabel={mutation.isPending ? "Wird vereinigt…" : "Vereinigen"}
triggerVariant="default" triggerVariant="default"
triggerSize="touch" triggerSize="touch"
triggerDisabled={!merged || mutation.isPending} triggerDisabled={!merged || mutation.isPending}
triggerAriaDisabled={!guard.canProceed}
triggerDescribedBy={guard.canProceed ? undefined : guard.noticeId}
title="Völker wirklich vereinigen?" title="Völker wirklich vereinigen?"
description={`Volk ${merged?.code ?? ""} geht in Volk ${colony.code} auf und wird beendet. Das lässt sich nicht rückgängig machen.`} description={`Volk ${merged?.code ?? ""} geht in Volk ${colony.code} auf und wird beendet. Das lässt sich nicht rückgängig machen.`}
confirmLabel={`${merged?.code ?? "Volk"} in ${colony.code} aufgehen lassen`} confirmLabel={`${merged?.code ?? "Volk"} in ${colony.code} aufgehen lassen`}
@@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ColonyEditCard } from "./colony-edit-card"; import { ColonyEditCard } from "./colony-edit-card";
import { updateColony } from "@/lib/colonies/api"; import { updateColony } from "@/lib/colonies/api";
import { fetchApiaries } from "@/lib/apiaries/api"; import { fetchApiaries } from "@/lib/apiaries/api";
import { fetchActiveRestrictions } from "@/lib/compliance/restrictions-api";
import type { Colony } from "@/lib/colonies/types"; import type { Colony } from "@/lib/colonies/types";
vi.mock("@/lib/colonies/api", () => ({ updateColony: vi.fn() })); vi.mock("@/lib/colonies/api", () => ({ updateColony: vi.fn() }));
@@ -12,6 +13,14 @@ vi.mock("@/lib/apiaries/api", () => ({
fetchApiaries: vi.fn().mockResolvedValue([{ id: "apiary-1", name: "Obstwiese" }]), fetchApiaries: vi.fn().mockResolvedValue([{ id: "apiary-1", name: "Obstwiese" }]),
})); }));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock("@/lib/compliance/restrictions-api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/compliance/restrictions-api")>()),
fetchActiveRestrictions: vi.fn().mockResolvedValue([]),
}));
let permissions: string[] = [];
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ hasPermission: (key: string) => permissions.includes(key) }),
}));
const colony: Colony = { const colony: Colony = {
id: "colony-1", id: "colony-1",
@@ -245,3 +254,76 @@ describe("ColonyEditCard – legacy strength after clearing", () => {
expect(screen.queryByText("Wird geladen…")).not.toBeInTheDocument(); 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");
});
});
@@ -18,7 +18,6 @@ import {
COLONY_TYPE_LABELS, COLONY_TYPE_LABELS,
MAX_OCCUPIED_FRAMES, MAX_OCCUPIED_FRAMES,
} from "@/lib/colonies/labels"; } from "@/lib/colonies/labels";
import { errorMessage } from "@/lib/forms/error-message";
import { emptyToNull } from "@/lib/forms/empty-to-null"; import { emptyToNull } from "@/lib/forms/empty-to-null";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -28,6 +27,9 @@ import { Textarea } from "@/components/ui/textarea";
import { RecordSelect } from "@/components/form/record-select"; import { RecordSelect } from "@/components/form/record-select";
import { EnumSelect } from "@/components/form/enum-select"; import { EnumSelect } from "@/components/form/enum-select";
import { NumberStepper } from "@/components/form/number-stepper"; 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 { import {
Card, Card,
CardContent, CardContent,
@@ -88,6 +90,13 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
colony.healthStatus, colony.healthStatus,
); );
const [notes, setNotes] = useState(colony.notes ?? ""); const [notes, setNotes] = useState(colony.notes ?? "");
const [conflictText, setConflictText] = useState<string | null>(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({ const apiariesQuery = useQuery({
queryKey: ["apiaries"], queryKey: ["apiaries"],
@@ -119,6 +128,7 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
feedStatus, feedStatus,
healthStatus, healthStatus,
notes: emptyToNull(notes), notes: emptyToNull(notes),
...guard.overrideFields(),
}), }),
onSuccess: () => { onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["colonies"] }); void queryClient.invalidateQueries({ queryKey: ["colonies"] });
@@ -126,12 +136,19 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
void queryClient.invalidateQueries({ queryKey: ["apiaries"] }); void queryClient.invalidateQueries({ queryKey: ["apiaries"] });
toast.success("Volk gespeichert"); toast.success("Volk gespeichert");
}, },
onError: (error) => onError: (error) => {
toast.error(errorMessage(error, "Volk konnte nicht gespeichert werden.")), 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<HTMLFormElement>) { function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
if (!guard.canProceed) return;
setConflictText(null);
updateMutation.mutate(); updateMutation.mutate();
} }
@@ -260,7 +277,38 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
onChange={(event) => setNotes(event.target.value)} onChange={(event) => setNotes(event.target.value)}
/> />
</div> </div>
<Button type="submit" size="touch" disabled={updateMutation.isPending}> <RestrictionGuardPanel guard={guard} blockedText={BLOCKED_TEXT.correct} />
{conflictText ? (
<p
ref={conflictRef}
role="alert"
tabIndex={-1}
className="text-sm font-medium text-destructive outline-none"
>
{conflictText}
</p>
) : null}
{conflictText && canReload ? (
<Button
type="button"
variant="outline"
size="touch"
onClick={() => {
void queryClient.invalidateQueries({ queryKey: ["colonies"] });
void queryClient.invalidateQueries({ queryKey: ["colonies", colony.id] });
setConflictText(null);
}}
>
Neu laden
</Button>
) : null}
<Button
type="submit"
size="touch"
disabled={updateMutation.isPending}
aria-disabled={guard.canProceed ? undefined : true}
aria-describedby={guard.canProceed ? undefined : guard.noticeId}
>
{updateMutation.isPending ? "Speichert…" : "Speichern"} {updateMutation.isPending ? "Speichert…" : "Speichern"}
</Button> </Button>
</form> </form>
@@ -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 }) => (
<p>
Dialog {treatment.id} {defaultOpen ? "offen" : "zu"}
</p>
),
}));
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(
<QueryClientProvider client={client}>
<ColonyTreatmentsCard colonyId="colony-1" editTreatmentId={editTreatmentId} />
</QueryClientProvider>,
);
}
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=<id>", 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();
});
});
@@ -10,7 +10,14 @@ import { QueryState } from "@/components/query-state";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { TreatmentEditDialog } from "./treatment-edit-dialog"; 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=<id>`). */
editTreatmentId?: string;
}) {
const treatmentsQuery = useQuery({ const treatmentsQuery = useQuery({
queryKey: ["colonies", colonyId, "treatments"], queryKey: ["colonies", colonyId, "treatments"],
queryFn: () => fetchColonyTreatments(colonyId), queryFn: () => fetchColonyTreatments(colonyId),
@@ -30,6 +37,11 @@ export function ColonyTreatmentsCard({ colonyId }: { colonyId: string }) {
</Button> </Button>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-3 text-sm"> <CardContent className="flex flex-col gap-3 text-sm">
<div role="status" aria-live="polite" className="empty:hidden">
{editTreatmentId && treatmentsQuery.data && !treatmentsQuery.data.some((t) => t.id === editTreatmentId) ? (
<p className="rounded-lg bg-warning/20 px-3 py-2">Behandlung nicht gefunden.</p>
) : null}
</div>
<QueryState <QueryState
query={treatmentsQuery} query={treatmentsQuery}
what="Behandlungen" what="Behandlungen"
@@ -48,7 +60,10 @@ export function ColonyTreatmentsCard({ colonyId }: { colonyId: string }) {
<p className="font-medium"> <p className="font-medium">
{new Date(treatment.date).toLocaleDateString("de-DE")} · {treatment.product} {new Date(treatment.date).toLocaleDateString("de-DE")} · {treatment.product}
</p> </p>
<TreatmentEditDialog treatment={treatment} /> <TreatmentEditDialog
treatment={treatment}
defaultOpen={treatment.id === editTreatmentId}
/>
</div> </div>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{treatment.amount ?? "—"} {treatment.amount ?? "—"}
@@ -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 { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -6,8 +6,10 @@ import ColonyDetailPage from "./page";
import { fetchColony } from "@/lib/colonies/api"; import { fetchColony } from "@/lib/colonies/api";
import type { Colony } from "@/lib/colonies/types"; import type { Colony } from "@/lib/colonies/types";
const search = vi.hoisted(() => ({ query: "" }));
vi.mock("next/navigation", () => ({ vi.mock("next/navigation", () => ({
useParams: () => ({ id: "colony-1" }), useParams: () => ({ id: "colony-1" }),
useSearchParams: () => new URLSearchParams(search.query),
})); }));
vi.mock("@/lib/colonies/api", () => ({ fetchColony: vi.fn() })); vi.mock("@/lib/colonies/api", () => ({ fetchColony: vi.fn() }));
vi.mock("@/lib/export/api", () => ({ vi.mock("@/lib/export/api", () => ({
@@ -21,7 +23,11 @@ vi.mock("./colony-history", () => ({ ColonyHistoryCard: () => null }));
vi.mock("./colony-inspections", () => ({ ColonyInspectionsCard: () => null })); vi.mock("./colony-inspections", () => ({ ColonyInspectionsCard: () => null }));
vi.mock("./colony-varroa", () => ({ ColonyVarroaCard: () => null })); vi.mock("./colony-varroa", () => ({ ColonyVarroaCard: () => null }));
vi.mock("./colony-swarm-control", () => ({ ColonySwarmControlCard: () => null })); vi.mock("./colony-swarm-control", () => ({ ColonySwarmControlCard: () => null }));
vi.mock("./colony-treatments", () => ({ ColonyTreatmentsCard: () => null })); vi.mock("./colony-treatments", () => ({
ColonyTreatmentsCard: ({ editTreatmentId }: { editTreatmentId?: string }) => (
<p>Behandlungen, Dialog für {editTreatmentId ?? "keine"}</p>
),
}));
vi.mock("./colony-feedings", () => ({ ColonyFeedingsCard: () => null })); vi.mock("./colony-feedings", () => ({ ColonyFeedingsCard: () => null }));
vi.mock("@/components/document-upload-card", () => ({ DocumentUploadCard: () => null })); vi.mock("@/components/document-upload-card", () => ({ DocumentUploadCard: () => null }));
vi.mock("@/components/autopilot/next-steps-card", () => ({ vi.mock("@/components/autopilot/next-steps-card", () => ({
@@ -121,3 +127,35 @@ describe("ColonyDetailPage – Als Nächstes", () => {
).toBeTruthy(); ).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=<id>", 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();
});
});
@@ -1,7 +1,7 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { useParams } from "next/navigation"; import { useParams, useSearchParams } from "next/navigation";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { ClipboardCheck, FileText, Layers } from "lucide-react"; import { ClipboardCheck, FileText, Layers } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -27,6 +27,8 @@ const BACK = { backHref: "/colonies", backLabel: "Alle Völker" };
export default function ColonyDetailPage() { export default function ColonyDetailPage() {
const params = useParams<{ id: string }>(); 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({ const colonyQuery = useQuery({
queryKey: ["colonies", params.id], queryKey: ["colonies", params.id],
queryFn: () => fetchColony(params.id), queryFn: () => fetchColony(params.id),
@@ -99,7 +101,7 @@ export default function ColonyDetailPage() {
<ColonyInspectionsCard colonyId={colony.id} /> <ColonyInspectionsCard colonyId={colony.id} />
<ColonyVarroaCard colonyId={colony.id} /> <ColonyVarroaCard colonyId={colony.id} />
<ColonySwarmControlCard colonyId={colony.id} /> <ColonySwarmControlCard colonyId={colony.id} />
<ColonyTreatmentsCard colonyId={colony.id} /> <ColonyTreatmentsCard colonyId={colony.id} editTreatmentId={editTreatmentId} />
<ColonyFeedingsCard colonyId={colony.id} /> <ColonyFeedingsCard colonyId={colony.id} />
<DocumentUploadCard entityType="COLONY" entityId={colony.id} /> <DocumentUploadCard entityType="COLONY" entityId={colony.id} />
</div> </div>
@@ -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<string>());
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(
<QueryClientProvider client={client}>
<TreatmentEditDialog treatment={treatment} />
</QueryClientProvider>,
);
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(
<QueryClientProvider client={client}>
<TreatmentEditDialog treatment={FREE_TEXT} defaultOpen />
</QueryClientProvider>,
);
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");
});
});
});
@@ -2,11 +2,25 @@
import { FormEvent, useState } from "react"; import { FormEvent, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/lib/auth/auth-context";
import { updateTreatment } from "@/lib/treatments/api"; import { updateTreatment } from "@/lib/treatments/api";
import { invalidateVarroaBands } from "@/lib/varroa/use-varroa-bands"; import { invalidateVarroaBands } from "@/lib/varroa/use-varroa-bands";
import { Treatment } from "@/lib/treatments/types"; import { Treatment } from "@/lib/treatments/types";
import { emptyToNull } from "@/lib/forms/empty-to-null"; 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 { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { ColonySelectField } from "@/components/colony-select-field"; import { ColonySelectField } from "@/components/colony-select-field";
@@ -27,19 +41,41 @@ import {
} from "./edit-dialog-parts"; } from "./edit-dialog-parts";
import { toApiDate } from "@/lib/dates"; import { toApiDate } from "@/lib/dates";
interface CatalogueState {
activeIngredient: string;
withdrawalPeriodDays: string;
method: string;
doseQuantity: string;
}
interface FieldErrors { interface FieldErrors {
date?: string; date?: string;
product?: 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=<id>`). */
defaultOpen?: boolean;
}) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [open, setOpen] = useState(false); const { hasPermission } = useAuth();
const [open, setOpen] = useState(defaultOpen);
const [colonyId, setColonyId] = useState(treatment.colonyId); const [colonyId, setColonyId] = useState(treatment.colonyId);
const [date, setDate] = useState(treatment.date.slice(0, 10)); const [date, setDate] = useState(treatment.date.slice(0, 10));
const [startDate, setStartDate] = useState(treatment.startDate?.slice(0, 10) ?? ""); const [startDate, setStartDate] = useState(treatment.startDate?.slice(0, 10) ?? "");
const [endDate, setEndDate] = useState(treatment.endDate?.slice(0, 10) ?? ""); const [endDate, setEndDate] = useState(treatment.endDate?.slice(0, 10) ?? "");
const [product, setProduct] = useState(treatment.product); 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 [activeIngredient, setActiveIngredient] = useState(treatment.activeIngredient ?? "");
const [amount, setAmount] = useState(treatment.amount ?? ""); const [amount, setAmount] = useState(treatment.amount ?? "");
const [method, setMethod] = useState(treatment.method ?? ""); const [method, setMethod] = useState(treatment.method ?? "");
@@ -55,6 +91,63 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({}); const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const [submitError, setSubmitError] = useState<string | null>(null); const [submitError, setSubmitError] = useState<string | null>(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<CatalogueState>(
(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<string | null | undefined>(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({ const mutation = useMutation({
mutationFn: () => mutationFn: () =>
updateTreatment(treatment.id, { updateTreatment(treatment.id, {
@@ -62,17 +155,22 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
date: toApiDate(date), date: toApiDate(date),
startDate: emptyToNull(startDate, toApiDate), startDate: emptyToNull(startDate, toApiDate),
endDate: emptyToNull(endDate, toApiDate), endDate: emptyToNull(endDate, toApiDate),
product,
activeIngredient: emptyToNull(activeIngredient), activeIngredient: emptyToNull(activeIngredient),
amount: emptyToNull(amount), amount: emptyToNull(amount),
method: emptyToNull(method), method: emptyToNull(method),
reason: emptyToNull(reason), reason: emptyToNull(reason),
batchNumber: emptyToNull(batchNumber),
notes: emptyToNull(notes), notes: emptyToNull(notes),
withdrawalPeriodDays: emptyToNull(withdrawalPeriodDays, Number), withdrawalPeriodDays: emptyToNull(withdrawalPeriodDays, Number),
supplier: emptyToNull(supplier), ...medicinePatch(treatment, {
purchaseProof: emptyToNull(purchaseProof), product,
prescribingVet: emptyToNull(prescribingVet), medicineId,
medicineLotId,
doseQuantity,
batchNumber,
supplier,
purchaseProof,
prescribingVet,
}),
}), }),
onSuccess: () => { onSuccess: () => {
// After re-filing, both the colony it left and the one it joined // 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. // A treatment moves the post-treatment window of the varroa lights.
void invalidateVarroaBands(queryClient); 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); setOpen(false);
}, },
onError: (error) => onError: (error) => setSubmitError(saveErrorText(error, SAVE_FAILED)),
setSubmitError(errorMessage(error, "Behandlung konnte nicht gespeichert werden.")),
}); });
function handleSubmit(event: FormEvent<HTMLFormElement>) { function handleSubmit(event: FormEvent<HTMLFormElement>) {
@@ -95,10 +197,11 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
setSubmitError(null); setSubmitError(null);
const errors: FieldErrors = { const errors: FieldErrors = {
date: date ? undefined : DATE_REQUIRED_MESSAGE, 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); setFieldErrors(errors);
if (errors.date || errors.product) { if (errors.date || errors.product || errors.doseQuantity) {
return; return;
} }
mutation.mutate(); mutation.mutate();
@@ -138,30 +241,48 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
</FormSection> </FormSection>
<FormSection step={2} title="Mittel"> <FormSection step={2} title="Mittel">
<Field label="Mittel" required error={fieldErrors.product}> <MedicineLotFields
{({ id, describedBy, invalid }) => ( idPrefix="treatment-edit"
<Input choice={choice}
id={id} product={product}
required medicineId={medicineId}
className="h-11" medicineLotId={medicineLotId}
aria-describedby={describedBy} doseQuantity={doseQuantity}
aria-invalid={invalid} productError={fieldErrors.product}
value={product} doseError={fieldErrors.doseQuantity}
onChange={(event) => { filled={catalogue.filled}
setProduct(event.target.value); onProductChange={(next) => {
setFieldErrors((current) => ({ ...current, product: undefined })); setProduct(next);
}} setFieldErrors((current) => ({ ...current, product: undefined }));
/> }}
)} onMedicineChange={chooseMedicine}
</Field> onLotChange={chooseLot}
onDoseChange={(next) => {
catalogue.edited("doseQuantity");
setDoseQuantity(next);
setFieldErrors((current) => ({ ...current, doseQuantity: undefined }));
}}
/>
{hasLot ? (
<p className="text-xs text-muted-foreground">
Änderungen an Packung oder Dosis werden gegengebucht.
</p>
) : null}
<Field label="Wirkstoff"> <Field label="Wirkstoff">
{({ id }) => ( {({ id }) => (
<Input <>
id={id} <Input
className="h-11" id={id}
value={activeIngredient} className="h-11"
onChange={(event) => setActiveIngredient(event.target.value)} aria-describedby={describedByCatalogue(id, undefined, catalogue.filled.activeIngredient)}
/> value={activeIngredient}
onChange={(event) => {
catalogue.edited("activeIngredient");
setActiveIngredient(event.target.value);
}}
/>
<CatalogueNote id={id} shown={catalogue.filled.activeIngredient} />
</>
)} )}
</Field> </Field>
<Field label="Menge"> <Field label="Menge">
@@ -176,12 +297,19 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
</Field> </Field>
<Field label="Methode"> <Field label="Methode">
{({ id }) => ( {({ id }) => (
<Input <>
id={id} <Input
className="h-11" id={id}
value={method} className="h-11"
onChange={(event) => setMethod(event.target.value)} aria-describedby={describedByCatalogue(id, undefined, catalogue.filled.method)}
/> value={method}
onChange={(event) => {
catalogue.edited("method");
setMethod(event.target.value);
}}
/>
<CatalogueNote id={id} shown={catalogue.filled.method} />
</>
)} )}
</Field> </Field>
<Field label="Grund"> <Field label="Grund">
@@ -223,15 +351,44 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
</div> </div>
<Field label="Wartezeit (Tage)"> <Field label="Wartezeit (Tage)">
{({ id }) => ( {({ id }) => (
<Input <>
id={id} <Input
type="number" id={id}
inputMode="numeric" type="number"
min={0} inputMode="numeric"
className="h-11" min={0}
value={withdrawalPeriodDays} className="h-11"
onChange={(event) => setWithdrawalPeriodDays(event.target.value)} aria-describedby={describedByCatalogue(id, undefined, catalogue.filled.withdrawalPeriodDays)}
/> value={withdrawalPeriodDays}
onChange={(event) => {
catalogue.edited("withdrawalPeriodDays");
setStaleWithdrawal(undefined);
setWithdrawalPeriodDays(event.target.value);
}}
/>
<CatalogueNote id={id} shown={catalogue.filled.withdrawalPeriodDays} />
<div role="status" aria-live="polite" className="empty:hidden">
{staleWithdrawal !== undefined ? (
<div className="flex flex-wrap items-center gap-2 rounded-lg bg-warning/20 px-3 py-2 text-sm">
<AlertTriangle className="size-4 shrink-0" aria-hidden />
<span>{STALE_WITHDRAWAL}</span>
{staleWithdrawal !== null ? (
<Button
type="button"
variant="outline"
size="touch"
onClick={() => {
setWithdrawalPeriodDays(staleWithdrawal);
setStaleWithdrawal(undefined);
}}
>
aus Katalog übernehmen
</Button>
) : null}
</div>
) : null}
</div>
</>
)} )}
</Field> </Field>
</FormSection> </FormSection>
@@ -241,49 +398,57 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
title="Für das Bestandsbuch" 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." description="Pflicht bei jedem Tierarzneimittel, auch bei freiverkäuflichen Säuren (Art. 108 VO (EU) 2019/6). Belege 5 Jahre aufbewahren."
> >
<Field label="Chargennummer"> {hasLot ? (
{({ id }) => ( <p className="rounded-lg bg-muted/60 px-3 py-2 text-sm">
<Input {choice.lot ? lotSourceLine(choice.lot) : "aus Packung"}
id={id} </p>
className="h-11" ) : (
value={batchNumber} <>
onChange={(event) => setBatchNumber(event.target.value)} <Field label="Chargennummer">
/> {({ id }) => (
)} <Input
</Field> id={id}
<Field label="Lieferant (Name, Anschrift)"> className="h-11"
{({ id }) => ( value={batchNumber}
<Input onChange={(event) => setBatchNumber(event.target.value)}
id={id} />
className="h-11" )}
placeholder="z. B. Imkereibedarf Muster, Hauptstr. 1, 86450 Altenmünster" </Field>
value={supplier} <Field label="Lieferant (Name, Anschrift)">
onChange={(event) => setSupplier(event.target.value)} {({ id }) => (
/> <Input
)} id={id}
</Field> className="h-11"
<Field label="Beleg für den Erwerb"> placeholder="z. B. Imkereibedarf Muster, Hauptstr. 1, 86450 Altenmünster"
{({ id }) => ( value={supplier}
<Input onChange={(event) => setSupplier(event.target.value)}
id={id} />
className="h-11" )}
placeholder="z. B. Rechnung Nr. 4711 vom 01.08.2026" </Field>
value={purchaseProof} <Field label="Beleg für den Erwerb">
onChange={(event) => setPurchaseProof(event.target.value)} {({ id }) => (
/> <Input
)} id={id}
</Field> className="h-11"
<Field label="Verschreibender Tierarzt" hint="Nur falls zutreffend."> placeholder="z. B. Rechnung Nr. 4711 vom 01.08.2026"
{({ id, describedBy }) => ( value={purchaseProof}
<Input onChange={(event) => setPurchaseProof(event.target.value)}
id={id} />
className="h-11" )}
aria-describedby={describedBy} </Field>
value={prescribingVet} <Field label="Verschreibender Tierarzt" hint="Nur falls zutreffend.">
onChange={(event) => setPrescribingVet(event.target.value)} {({ id, describedBy }) => (
/> <Input
)} id={id}
</Field> className="h-11"
aria-describedby={describedBy}
value={prescribingVet}
onChange={(event) => setPrescribingVet(event.target.value)}
/>
)}
</Field>
</>
)}
</FormSection> </FormSection>
<FormSection <FormSection
@@ -1,10 +1,12 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react"; import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import NewColonyPage from "./page"; import NewColonyPage from "./page";
import { createColony } from "@/lib/colonies/api"; import { createColony } from "@/lib/colonies/api";
import { fetchApiaries } from "@/lib/apiaries/api"; import { fetchApiaries } from "@/lib/apiaries/api";
import { fetchActiveRestrictions } from "@/lib/compliance/restrictions-api";
import { ApiError } from "@/lib/auth/api-client";
const push = vi.fn(); const push = vi.fn();
vi.mock("next/navigation", () => ({ vi.mock("next/navigation", () => ({
@@ -13,6 +15,15 @@ vi.mock("next/navigation", () => ({
})); }));
vi.mock("@/lib/colonies/api", () => ({ createColony: vi.fn() })); vi.mock("@/lib/colonies/api", () => ({ createColony: vi.fn() }));
vi.mock("@/lib/apiaries/api", () => ({ fetchApiaries: vi.fn() })); vi.mock("@/lib/apiaries/api", () => ({ fetchApiaries: vi.fn() }));
vi.mock("@/lib/compliance/restrictions-api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/compliance/restrictions-api")>()),
fetchActiveRestrictions: vi.fn().mockResolvedValue([]),
}));
let permissions: string[] = [];
vi.mock("@/lib/auth/auth-context", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/auth/auth-context")>()),
useAuth: () => ({ hasPermission: (key: string) => permissions.includes(key) }),
}));
function renderPage() { function renderPage() {
const client = new QueryClient({ const client = new QueryClient({
@@ -113,3 +124,81 @@ describe("NewColonyPage – besetzte Waben", () => {
expect(field).toHaveValue("40"); 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());
});
});
@@ -9,7 +9,6 @@ import { createColony } from "@/lib/colonies/api";
import { COLONY_TYPES, COLONY_TYPE_LABELS, MAX_OCCUPIED_FRAMES } from "@/lib/colonies/labels"; import { COLONY_TYPES, COLONY_TYPE_LABELS, MAX_OCCUPIED_FRAMES } from "@/lib/colonies/labels";
import { fetchApiaries } from "@/lib/apiaries/api"; import { fetchApiaries } from "@/lib/apiaries/api";
import { useFormDraft } from "@/lib/forms/use-form-draft"; import { useFormDraft } from "@/lib/forms/use-form-draft";
import { errorMessage } from "@/lib/forms/error-message";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { EnumSelect } from "@/components/form/enum-select"; 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 { FormSection } from "@/components/form/form-section";
import { StickySubmit } from "@/components/form/sticky-submit"; import { StickySubmit } from "@/components/form/sticky-submit";
import { DraftBanner } from "@/components/form/draft-banner"; 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 { import {
ColonyFormErrors, ColonyFormErrors,
ColonyFormValues, ColonyFormValues,
@@ -42,6 +44,9 @@ export default function NewColonyPage() {
const [dirty, setDirty] = useState(false); const [dirty, setDirty] = useState(false);
const [errors, setErrors] = useState<ColonyFormErrors>({}); const [errors, setErrors] = useState<ColonyFormErrors>({});
const [submitError, setSubmitError] = useState<string | null>(null); const [submitError, setSubmitError] = useState<string | null>(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); const draft = useFormDraft("colony-new", values, dirty);
function set<K extends keyof ColonyFormValues>( function set<K extends keyof ColonyFormValues>(
@@ -54,7 +59,8 @@ export default function NewColonyPage() {
} }
const mutation = useMutation({ const mutation = useMutation({
mutationFn: createColony, mutationFn: (input: ReturnType<typeof toColonyInput>) =>
createColony({ ...input, ...guard.overrideFields() }),
onSuccess: (colony) => { onSuccess: (colony) => {
draft.clear(); draft.clear();
void queryClient.invalidateQueries({ queryKey: ["colonies"] }); void queryClient.invalidateQueries({ queryKey: ["colonies"] });
@@ -62,14 +68,15 @@ export default function NewColonyPage() {
router.push(`/colonies/${colony.id}`); router.push(`/colonies/${colony.id}`);
}, },
onError: (mutationError) => { onError: (mutationError) => {
setSubmitError( const explained = guard.explain(mutationError, "Volk konnte nicht angelegt werden.");
errorMessage(mutationError, "Volk konnte nicht angelegt werden."), setSubmitError(explained.text);
); setFocusError(explained.focus);
}, },
}); });
function handleSubmit(event: FormEvent<HTMLFormElement>) { function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
if (!guard.canProceed) return;
setSubmitError(null); setSubmitError(null);
const found = validateColony(values); const found = validateColony(values);
setErrors(found); setErrors(found);
@@ -223,12 +230,16 @@ export default function NewColonyPage() {
</Field> </Field>
</FormSection> </FormSection>
<RestrictionGuardPanel guard={guard} blockedText={BLOCKED_TEXT.create} />
<StickySubmit <StickySubmit
note={ note={
submitError ? ( submitError ? (
<p <p
ref={errorRef}
role="alert" role="alert"
className="text-sm font-medium text-destructive" tabIndex={-1}
className="text-sm font-medium text-destructive outline-none"
> >
{submitError} {submitError}
</p> </p>
@@ -239,6 +250,8 @@ export default function NewColonyPage() {
type="submit" type="submit"
className="h-12 text-base" className="h-12 text-base"
disabled={mutation.isPending} disabled={mutation.isPending}
aria-disabled={guard.canProceed ? undefined : true}
aria-describedby={guard.canProceed ? undefined : guard.noticeId}
> >
{mutation.isPending ? "Wird angelegt…" : "Volk anlegen"} {mutation.isPending ? "Wird angelegt…" : "Volk anlegen"}
</Button> </Button>
@@ -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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button
id={MARK_SENT_TRIGGER_ID}
size="touch"
variant={primary ? "default" : "outline"}
disabled={disabled}
aria-describedby={describedBy}
>
<Send aria-hidden />
Als gesendet markieren
</Button>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>Als gesendet markieren</DialogTitle>
</DialogHeader>
<MarkSentForm
noticeId={noticeId}
onDone={(notice) => {
setOpen(false);
onDone(notice);
}}
/>
</DialogContent>
</Dialog>
);
}
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<string | null>(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<HTMLFormElement>) {
event.preventDefault();
setError(null);
if (isOffline) return;
mutation.mutate();
}
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit} noValidate>
<div className="flex flex-col gap-2">
<Label htmlFor="sent-on">Gesendet am</Label>
<Input id="sent-on" type="date" value={sentOn} onChange={(event) => setSentOn(event.target.value)} />
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="sent-via">Gesendet über</Label>
<Input
id="sent-via"
value={sentVia}
maxLength={300}
placeholder="z. B. E-Mail, Post"
onChange={(event) => setSentVia(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="sent-reference">Aktenzeichen</Label>
<Input
id="sent-reference"
value={reference}
maxLength={300}
placeholder="falls das Amt eins nennt"
onChange={(event) => setReference(event.target.value)}
/>
</div>
<div aria-live="assertive">
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
</div>
<OnlineOnlyHint id={hintId} />
<Button type="submit" size="touch" disabled={mutation.isPending || isOffline} aria-describedby={describedBy}>
{mutation.isPending ? "Speichert…" : "Bestätigen"}
</Button>
</form>
);
}
@@ -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<NoticeEdit>;
/** Messages per field name; shown at the field while they apply. */
problems: Partial<Record<keyof FormValues, string>>;
}
/** 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<NoticeEdit> = {};
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 (
<p id={id} role="alert" className="text-sm text-destructive">
{text}
</p>
);
}
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<FormValues>(() => valuesOf(notice));
const [error, setError] = useState<string | null>(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<NoticeEdit>) => updateNotice(notice.id, edit),
onSuccess: onSaved,
onError: (mutationError) => setError(complianceErrorText(mutationError, "Die Änderungen wurden nicht gespeichert.")),
});
function set<K extends keyof FormValues>(key: K, value: FormValues[K]) {
setValues((current) => ({ ...current, [key]: value }));
}
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
if (hasProblems || isOffline || !hasChanges) return;
mutation.mutate(changes);
}
return (
<form onSubmit={handleSubmit} noValidate>
<FormSection title="Angaben ändern" collapsible defaultOpen={false} fieldCount={isSent ? 6 : 3}>
<div className="flex flex-col gap-2">
<Label htmlFor="edit-total">Angezeigte Völker</Label>
<Input
id="edit-total"
aria-invalid={problems.totalColonies ? true : undefined}
aria-describedby={problems.totalColonies ? "problem-totalColonies" : undefined}
inputMode="numeric"
value={values.totalColonies}
onChange={(event) => set("totalColonies", event.target.value)}
/>
<FieldProblem id="problem-totalColonies" text={problems.totalColonies} />
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="edit-reference-date">Stichtag</Label>
<Input
id="edit-reference-date"
aria-invalid={problems.referenceDate ? true : undefined}
aria-describedby={problems.referenceDate ? "problem-referenceDate" : undefined}
type="date"
value={values.referenceDate}
onChange={(event) => set("referenceDate", event.target.value)}
/>
<FieldProblem id="problem-referenceDate" text={problems.referenceDate} />
</div>
{isSent ? (
<>
<div className="flex flex-col gap-2">
<Label htmlFor="edit-sent-on">Gesendet am</Label>
<Input
id="edit-sent-on"
aria-invalid={problems.sentOn ? true : undefined}
aria-describedby={problems.sentOn ? "problem-sentOn" : undefined}
type="date"
value={values.sentOn}
onChange={(event) => set("sentOn", event.target.value)}
/>
<FieldProblem id="problem-sentOn" text={problems.sentOn} />
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="edit-sent-via">Gesendet über</Label>
<Input
id="edit-sent-via"
value={values.sentVia}
maxLength={300}
onChange={(event) => set("sentVia", event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="edit-reference">Aktenzeichen</Label>
<Input
id="edit-reference"
value={values.reference}
maxLength={300}
onChange={(event) => set("reference", event.target.value)}
/>
</div>
</>
) : null}
<div className="flex flex-col gap-2">
<Label htmlFor="edit-notes">Notizen</Label>
<Textarea
id="edit-notes"
value={values.notes}
maxLength={2000}
onChange={(event) => set("notes", event.target.value)}
/>
</div>
<div aria-live="assertive">
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
</div>
<OnlineOnlyHint id={hintId} />
<Button
type="submit"
size="touch"
variant="outline"
disabled={mutation.isPending || isOffline || !hasChanges || hasProblems}
aria-describedby={describedBy}
>
{mutation.isPending ? "Speichert…" : "Angaben speichern"}
</Button>
</FormSection>
</form>
);
}
@@ -0,0 +1,50 @@
import { COLONY_TYPE_LABELS } from "@/lib/colonies/labels";
import type { ColonyType } from "@/lib/colonies/types";
import type { AuthorityNotice, HerdApiarySnapshot } from "@/lib/compliance/notice-types";
function coloniesText(count: number): string {
return `${count} ${count === 1 ? "Volk" : "Völker"}`;
}
function typesText(byType: HerdApiarySnapshot["byType"]): string {
return (Object.entries(byType) as [ColonyType, number][])
.filter(([, count]) => count > 0)
.map(([type, count]) => `${COLONY_TYPE_LABELS[type] ?? type}: ${count}`)
.join(", ");
}
function placeOf(apiary: HerdApiarySnapshot): string {
return [apiary.address, apiary.location].filter(Boolean).join(", ");
}
/** The herd as it was counted: the shown total and every apiary with its colonies. */
export function NoticeHerd({ notice }: { notice: AuthorityNotice }) {
const { snapshot } = notice;
return (
<section aria-labelledby="notice-herd-heading" className="flex flex-col gap-3 rounded-xl border border-border/60 bg-card p-4">
<h2 id="notice-herd-heading" className="font-heading text-lg font-semibold tracking-tight">
Bestand zum Stichtag
</h2>
<p className="text-sm font-medium">{coloniesText(notice.totalColonies)} angezeigt</p>
{snapshot.totalLiving !== notice.totalColonies ? (
<p className="text-sm text-muted-foreground">Gezählt waren {coloniesText(snapshot.totalLiving)}.</p>
) : null}
{snapshot.apiaries.length === 0 ? (
<p className="text-sm text-muted-foreground">Keine Stände im Bestand.</p>
) : (
<ul className="flex flex-col gap-2">
{snapshot.apiaries.map((apiary) => (
<li key={apiary.apiaryId} className="flex flex-col gap-0.5 rounded-lg bg-muted/50 p-3 text-sm">
<span className="flex flex-wrap items-baseline justify-between gap-2">
<span className="font-medium">{apiary.name}</span>
<span>{coloniesText(apiary.livingColonies)}</span>
</span>
{placeOf(apiary) ? <span className="text-muted-foreground">{placeOf(apiary)}</span> : null}
{typesText(apiary.byType) ? <span className="text-muted-foreground">{typesText(apiary.byType)}</span> : null}
</li>
))}
</ul>
)}
</section>
);
}
@@ -0,0 +1,84 @@
import Link from "next/link";
import { ArrowRight, Check, Circle, type LucideIcon } from "lucide-react";
import type { ComplianceSettings } from "@/lib/compliance/notice-types";
type StepState = "done" | "current" | "upcoming";
const STEP_ICON: Record<StepState, LucideIcon> = {
done: Check,
current: ArrowRight,
upcoming: Circle,
};
const STEP_WORD: Record<StepState, string> = {
done: "Erledigt",
current: "Jetzt",
upcoming: "Danach",
};
interface StepProps {
number: number;
title: string;
state: StepState;
children?: React.ReactNode;
}
function Step({ number, title, state, children }: StepProps) {
const Icon = STEP_ICON[state];
return (
<li
aria-current={state === "current" ? "step" : undefined}
className="flex flex-col gap-1 rounded-lg border border-border/60 p-3 aria-[current=step]:border-primary aria-[current=step]:bg-primary/10"
>
<span className="flex flex-wrap items-center justify-between gap-2">
<span className="font-heading text-base font-semibold">{`${number}. ${title}`}</span>
<span className="inline-flex items-center gap-1.5 text-sm font-medium">
<Icon className="size-4" aria-hidden />
{STEP_WORD[state]}
</span>
</span>
{children ? <span className="text-sm text-muted-foreground">{children}</span> : null}
</li>
);
}
function OfficeText({ settings, canManage }: { settings: ComplianceSettings | undefined; canManage: boolean }) {
const name = settings?.veterinaryOfficeName?.trim();
if (!name) {
return (
<>
Schick das PDF an dein Veterinäramt.{" "}
{canManage ? (
<Link href="/compliance/settings" className="underline underline-offset-2">
Veterinäramt eintragen
</Link>
) : null}
</>
);
}
const address = settings?.veterinaryOfficeAddress?.trim();
return <>{`Schick das PDF an ${name}${address ? `, ${address}` : ""}.`}</>;
}
interface NoticeStepsProps {
pdfOpened: boolean;
settings: ComplianceSettings | undefined;
canManage: boolean;
}
/** The way through a draft: open the PDF, send it, confirm here. Symbol plus word per step. */
export function NoticeSteps({ pdfOpened, settings, canManage }: NoticeStepsProps) {
return (
<ol aria-label="Schritte" className="flex flex-col gap-2">
<Step number={1} title="PDF öffnen" state={pdfOpened ? "done" : "current"}>
Prüf die Angaben im PDF.
</Step>
<Step number={2} title="Ans Amt schicken" state={pdfOpened ? "current" : "upcoming"}>
<OfficeText settings={settings} canManage={canManage} />
</Step>
<Step number={3} title="Hier bestätigen" state="upcoming">
Markiere die Anzeige als gesendet, sobald sie raus ist.
</Step>
</ol>
);
}
@@ -0,0 +1,252 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import NoticeDetailPage from "./page";
import { ApiError } from "@/lib/auth/api-client";
import { noticeFixture, settingsFixture } from "@/lib/compliance/notice-fixtures";
import { fetchNotice, noticeAction, openNoticePdf, updateNotice } from "@/lib/compliance/notices-api";
import { fetchComplianceSettings } from "@/lib/compliance/settings-api";
let permissions = ["compliance:read", "compliance:manage"];
vi.mock("next/navigation", () => ({ useParams: () => ({ id: "n1" }) }));
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ hasPermission: (key: string) => permissions.includes(key) }),
}));
vi.mock("@/lib/offline/connectivity", () => ({ useConnectivity: () => "online" }));
vi.mock("@/lib/compliance/notices-api", () => ({
fetchNotice: vi.fn(),
noticeAction: vi.fn(),
openNoticePdf: vi.fn(),
updateNotice: vi.fn(),
}));
vi.mock("@/lib/compliance/settings-api", () => ({ fetchComplianceSettings: vi.fn() }));
vi.mock("@/components/document-upload-card", () => ({
DocumentUploadCard: ({ entityType, entityId, canWrite }: { entityType: string; entityId: string; canWrite?: boolean }) => (
<p>
Upload {entityType} {entityId} {canWrite ? "schreiben" : "nur lesen"}
</p>
),
}));
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<NoticeDetailPage />
</QueryClientProvider>,
);
}
function steps() {
return within(screen.getByRole("list", { name: "Schritte" })).getAllByRole("listitem");
}
describe("NoticeDetailPage", () => {
beforeEach(() => {
permissions = ["compliance:read", "compliance:manage"];
vi.mocked(fetchNotice).mockReset();
vi.mocked(noticeAction).mockReset();
vi.mocked(openNoticePdf).mockReset();
vi.mocked(updateNotice).mockReset();
vi.mocked(fetchComplianceSettings).mockReset();
vi.mocked(fetchComplianceSettings).mockResolvedValue(settingsFixture());
vi.mocked(openNoticePdf).mockResolvedValue(undefined);
});
it("shows three steps: 1. PDF öffnen, 2. ans Amt schicken, 3. hier bestätigen, and the main action follows the step", async () => {
vi.mocked(fetchNotice).mockResolvedValue(noticeFixture());
renderPage();
await screen.findByRole("list", { name: "Schritte" });
let items = steps();
expect(items).toHaveLength(3);
expect(items[0]).toHaveTextContent("1. PDF öffnen");
expect(items[1]).toHaveTextContent("2. Ans Amt schicken");
expect(items[2]).toHaveTextContent("3. Hier bestätigen");
// Symbol plus word per step, never colour alone.
expect(items[0]).toHaveTextContent("Jetzt");
expect(items[1]).toHaveTextContent("Danach");
for (const item of items) expect(item.querySelector("svg")).not.toBeNull();
expect(await within(items[1]).findByText(/Veterinäramt Musterstadt/)).toBeInTheDocument();
// Step 1 is current: the primary button opens the PDF, marking as sent is only secondary.
expect(screen.getByRole("button", { name: "Als gesendet markieren" })).not.toHaveClass("bg-primary");
await userEvent.click(screen.getByRole("button", { name: "PDF öffnen" }));
expect(openNoticePdf).toHaveBeenCalledWith("n1");
// Now the main action is step 3, it has the focus and a polite message says what to do.
const mark = await screen.findByRole("button", { name: "Als gesendet markieren" });
expect(mark).toHaveClass("bg-primary");
expect(mark).toHaveFocus();
expect(screen.getByRole("status")).toHaveTextContent("PDF heruntergeladen. Schick es jetzt ans Amt.");
expect(screen.queryByRole("button", { name: "PDF öffnen" })).not.toBeInTheDocument();
items = steps();
expect(items[0]).toHaveTextContent("Erledigt");
expect(items[1]).toHaveTextContent("Jetzt");
});
it("offers Als gesendet markieren even after a reload, without opening the PDF again", async () => {
vi.mocked(fetchNotice).mockResolvedValue(noticeFixture());
vi.mocked(noticeAction).mockResolvedValue(noticeFixture({ status: "SENT" }));
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Als gesendet markieren" }));
await userEvent.click(screen.getByRole("button", { name: "Bestätigen" }));
expect(openNoticePdf).not.toHaveBeenCalled();
expect(noticeAction).toHaveBeenCalledWith("n1", "mark-sent", expect.any(Object));
});
it("defaults Gesendet am to the Berlin day, not the device day", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2027-03-01T23:30:00Z"));
try {
vi.mocked(fetchNotice).mockResolvedValue(noticeFixture());
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Als gesendet markieren" }));
expect(screen.getByLabelText("Gesendet am")).toHaveValue("2027-03-02");
} finally {
vi.useRealTimers();
}
});
it("shows German text when the PDF download fails with 403 or a server error", async () => {
vi.mocked(fetchNotice).mockResolvedValue(noticeFixture());
vi.mocked(openNoticePdf).mockRejectedValueOnce(new ApiError(403, "Forbidden resource"));
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "PDF öffnen" }));
expect(await screen.findByText("Dazu fehlt dir das Recht.")).toBeInTheDocument();
vi.mocked(openNoticePdf).mockRejectedValueOnce(new ApiError(500, "Internal server error"));
await userEvent.click(screen.getByRole("button", { name: "PDF öffnen" }));
expect(await screen.findByText(/Der Server hat einen Fehler gemeldet/)).toBeInTheDocument();
expect(screen.queryByText("Dazu fehlt dir das Recht.")).not.toBeInTheDocument();
});
it("a cleared reference day or sent day shows that it cannot be removed", async () => {
vi.mocked(fetchNotice).mockResolvedValue(
noticeFixture({ status: "SENT", sentOn: "2027-01-15T00:00:00.000Z" }),
);
renderPage();
await userEvent.click(await screen.findByRole("button", { name: /Angaben ändern/ }));
fireEvent.change(screen.getByLabelText("Stichtag"), { target: { value: "" } });
fireEvent.change(screen.getByLabelText("Gesendet am"), { target: { value: "" } });
const alerts = screen.getAllByRole("alert");
expect(alerts.map((alert) => alert.textContent)).toEqual([
"Das Datum kann nicht entfernt werden.",
"Das Datum kann nicht entfernt werden.",
]);
expect(screen.getByLabelText("Stichtag")).toHaveAccessibleDescription("Das Datum kann nicht entfernt werden.");
expect(screen.getByRole("button", { name: "Angaben speichern" })).toBeDisabled();
});
it("Als gesendet markieren asks for date, channel and reference and sends them", async () => {
vi.mocked(fetchNotice).mockResolvedValue(noticeFixture());
vi.mocked(noticeAction).mockResolvedValue(noticeFixture({ status: "SENT" }));
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "PDF öffnen" }));
await userEvent.click(await screen.findByRole("button", { name: "Als gesendet markieren" }));
fireEvent.change(screen.getByLabelText("Gesendet am"), { target: { value: "2027-01-15" } });
await userEvent.type(screen.getByLabelText("Gesendet über"), "E-Mail");
await userEvent.type(screen.getByLabelText("Aktenzeichen"), "AZ 12/27");
await userEvent.click(screen.getByRole("button", { name: "Bestätigen" }));
expect(noticeAction).toHaveBeenCalledWith("n1", "mark-sent", {
sentOn: "2027-01-15",
sentVia: "E-Mail",
reference: "AZ 12/27",
});
});
it("shows the server message when marking as sent fails", async () => {
vi.mocked(fetchNotice).mockResolvedValue(noticeFixture());
vi.mocked(noticeAction).mockRejectedValue(new ApiError(409, "Nur ein Entwurf kann gesendet werden.", { message: "Nur ein Entwurf kann gesendet werden." }));
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "PDF öffnen" }));
await userEvent.click(await screen.findByRole("button", { name: "Als gesendet markieren" }));
await userEvent.click(screen.getByRole("button", { name: "Bestätigen" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Nur ein Entwurf kann gesendet werden.");
});
it("recount is offered only for drafts and cancel only for drafts", async () => {
vi.mocked(fetchNotice).mockResolvedValue(noticeFixture({ status: "DRAFT" }));
vi.mocked(noticeAction).mockResolvedValue(noticeFixture({ totalColonies: 9 }));
const { unmount } = renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Neu zählen" }));
expect(noticeAction).toHaveBeenCalledWith("n1", "recount");
expect(screen.getByRole("button", { name: "Anzeige stornieren" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Wieder öffnen" })).not.toBeInTheDocument();
unmount();
vi.mocked(fetchNotice).mockResolvedValue(noticeFixture({ status: "SENT", sentOn: "2027-01-15T00:00:00.000Z", sentVia: "E-Mail", reference: "AZ 1" }));
const sent = renderPage();
expect(await screen.findByRole("button", { name: "Wieder öffnen" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Neu zählen" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Anzeige stornieren" })).not.toBeInTheDocument();
expect(screen.getByText(/Gesendet am 15\.01\.2027/)).toBeInTheDocument();
sent.unmount();
vi.mocked(fetchNotice).mockResolvedValue(noticeFixture({ status: "CANCELLED" }));
renderPage();
await screen.findByText("Storniert");
expect(screen.queryByRole("button", { name: "Neu zählen" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Anzeige stornieren" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Wieder öffnen" })).not.toBeInTheDocument();
});
it("cancelling asks first and then posts the cancel action", async () => {
vi.mocked(fetchNotice).mockResolvedValue(noticeFixture());
vi.mocked(noticeAction).mockResolvedValue(noticeFixture({ status: "CANCELLED" }));
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Anzeige stornieren" }));
expect(noticeAction).not.toHaveBeenCalled();
await userEvent.click(screen.getByRole("button", { name: "Ja, stornieren" }));
expect(noticeAction).toHaveBeenCalledWith("n1", "cancel");
});
it("lets a manager correct the shown count and sends only what changed", async () => {
vi.mocked(fetchNotice).mockResolvedValue(noticeFixture({ totalColonies: 7 }));
vi.mocked(updateNotice).mockResolvedValue(noticeFixture({ totalColonies: 9 }));
renderPage();
await userEvent.click(await screen.findByRole("button", { name: /Angaben ändern/ }));
const count = screen.getByLabelText("Angezeigte Völker");
await userEvent.clear(count);
await userEvent.type(count, "9");
await userEvent.click(screen.getByRole("button", { name: "Angaben speichern" }));
expect(updateNotice).toHaveBeenCalledWith("n1", { totalColonies: 9 });
});
it("attaches copy and receipt to the notice, writable only for a manager", async () => {
vi.mocked(fetchNotice).mockResolvedValue(noticeFixture());
renderPage();
expect(await screen.findByText("Upload AUTHORITY_NOTICE n1 schreiben")).toBeInTheDocument();
});
it("the herd of the notice is listed per apiary", async () => {
vi.mocked(fetchNotice).mockResolvedValue(noticeFixture());
renderPage();
const region = await screen.findByRole("region", { name: "Bestand zum Stichtag" });
expect(region).toHaveTextContent("Waldrand");
expect(region).toHaveTextContent("7 Völker");
expect(region).toHaveTextContent("Wirtschaftsvolk: 5");
expect(region).toHaveTextContent("Ableger: 2");
});
it("STAFF sees the page read-only without action buttons", async () => {
permissions = ["compliance:read"];
vi.mocked(fetchNotice).mockResolvedValue(noticeFixture());
renderPage();
await screen.findByRole("list", { name: "Schritte" });
for (const name of ["Als gesendet markieren", "Neu zählen", "Anzeige stornieren", "Wieder öffnen", "Angaben ändern"]) {
expect(screen.queryByRole("button", { name: new RegExp(name) })).not.toBeInTheDocument();
}
expect(screen.getByText("Upload AUTHORITY_NOTICE n1 nur lesen")).toBeInTheDocument();
// Reading the PDF stays possible.
expect(screen.getByRole("button", { name: "PDF öffnen" })).toBeInTheDocument();
});
});
@@ -0,0 +1,239 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useParams } from "next/navigation";
import { BookOpen, FileText, RotateCcw } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { NoComplianceAccess } from "@/components/compliance/no-compliance-access";
import { NoticeStatus } from "@/components/compliance/notice-status";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { DetailHeader, DetailQueryFallback } from "@/components/detail-header";
import { DocumentUploadCard } from "@/components/document-upload-card";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/lib/auth/auth-context";
import { complianceErrorText } from "@/lib/compliance/errors";
import { noticeDay, noticeTitle } from "@/lib/compliance/notice-labels";
import type { AuthorityNotice } from "@/lib/compliance/notice-types";
import { fetchNotice, noticeAction, openNoticePdf } from "@/lib/compliance/notices-api";
import { fetchComplianceSettings } from "@/lib/compliance/settings-api";
import { complianceKey } from "@/lib/compliance/use-readiness";
import { formatDay } from "@/lib/medicines/format";
import { MARK_SENT_TRIGGER_ID, MarkSentDialog } from "./mark-sent-dialog";
import { NoticeEditForm } from "./notice-edit-form";
import { NoticeHerd } from "./notice-herd";
import { NoticeSteps } from "./notice-steps";
type SimpleAction = "recount" | "reopen" | "cancel";
function doneMessage(action: SimpleAction, notice: AuthorityNotice): string {
if (action === "recount") return `Neu gezählt: ${notice.totalColonies} Völker.`;
if (action === "reopen") return "Wieder geöffnet. Die Anzeige ist wieder ein Entwurf.";
return "Anzeige storniert.";
}
function sentText(notice: AuthorityNotice): string {
const parts = [`Gesendet am ${formatDay(noticeDay(notice.sentOn))}`];
if (notice.sentVia) parts.push(`über ${notice.sentVia}`);
if (notice.reference) parts.push(`Aktenzeichen ${notice.reference}`);
return parts.join(" · ");
}
export default function NoticeDetailPage() {
const params = useParams<{ id: string }>();
const { hasPermission } = useAuth();
const canRead = hasPermission("compliance:read");
const canManage = hasPermission("compliance:manage");
const queryClient = useQueryClient();
const { isOffline, hintId, describedBy } = useOnlineOnly();
const [pdfOpened, setPdfOpened] = useState(false);
const [message, setMessage] = useState("");
const [actionError, setActionError] = useState<string | null>(null);
const flowHeading = useRef<HTMLHeadingElement>(null);
const noticeKey = [...complianceKey, "notices", params.id];
const query = useQuery({ queryKey: noticeKey, queryFn: () => fetchNotice(params.id), enabled: canRead });
const notice = query.data;
const settingsQuery = useQuery({
queryKey: [...complianceKey, "settings"],
queryFn: fetchComplianceSettings,
enabled: canRead && notice?.status === "DRAFT",
retry: false,
});
function applyNotice(next: AuthorityNotice, text: string) {
queryClient.setQueryData(noticeKey, next);
void queryClient.invalidateQueries({ queryKey: complianceKey });
setActionError(null);
setMessage(text);
flowHeading.current?.focus();
}
const pdf = useMutation({
mutationFn: () => openNoticePdf(params.id),
onMutate: () => setActionError(null),
onSuccess: () => {
setPdfOpened(true);
setMessage("PDF heruntergeladen. Schick es jetzt ans Amt.");
},
onError: (error) => setActionError(complianceErrorText(error, "Das PDF konnte nicht geladen werden.")),
});
const simple = useMutation({
mutationFn: (action: SimpleAction) => noticeAction(params.id, action),
onMutate: () => setActionError(null),
onSuccess: (next, action) => {
if (action === "reopen") setPdfOpened(false);
applyNotice(next, doneMessage(action, next));
},
onError: (error) => setActionError(complianceErrorText(error, "Das hat nicht geklappt.")),
});
// Once the PDF is out, "Als gesendet markieren" becomes the main action: the focus follows.
useEffect(() => {
if (pdfOpened) document.getElementById(MARK_SENT_TRIGGER_ID)?.focus();
}, [pdfOpened]);
if (!canRead) return <NoComplianceAccess />;
if (query.isLoading || query.isError || !notice) {
return (
<DetailQueryFallback query={query} what="Die Anzeige" backHref="/compliance/notices" backLabel="Alle Anzeigen" />
);
}
const isDraft = notice.status === "DRAFT";
const offlineReason = isOffline ? describedBy : undefined;
function pdfButton() {
return (
<Button size="touch" disabled={isOffline || pdf.isPending} aria-describedby={offlineReason} onClick={() => pdf.mutate()}>
<FileText aria-hidden />
PDF öffnen
</Button>
);
}
function primaryAction() {
if (isDraft && canManage && pdfOpened) {
return (
<MarkSentDialog
noticeId={notice!.id}
disabled={isOffline}
describedBy={offlineReason}
onDone={(next) => applyNotice(next, "Als gesendet markiert.")}
/>
);
}
return notice!.status === "CANCELLED" ? undefined : pdfButton();
}
const flowTitle = isDraft ? "Ablauf" : notice.status === "SENT" ? "Versand" : "Verworfen";
return (
<main className="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4 sm:p-6">
<DetailHeader
icon={BookOpen}
title={noticeTitle(notice)}
subtitle={`Stichtag ${formatDay(noticeDay(notice.referenceDate))}`}
status={<NoticeStatus status={notice.status} />}
primaryAction={primaryAction()}
moreActions={
isDraft && pdfOpened
? [{ label: "PDF nochmal öffnen", icon: FileText, onSelect: () => pdf.mutate(), disabled: isOffline || pdf.isPending }]
: undefined
}
backHref="/compliance/notices"
backLabel="Alle Anzeigen"
/>
<OnlineOnlyHint id={hintId} />
<div role="status" aria-live="polite" className="min-h-5 text-sm font-medium">
{message}
</div>
<div aria-live="assertive">
{actionError ? <p className="text-sm font-medium text-destructive">{actionError}</p> : null}
</div>
<section aria-labelledby="notice-flow-heading" className="flex flex-col gap-3">
<h2
id="notice-flow-heading"
ref={flowHeading}
tabIndex={-1}
className="font-heading text-lg font-semibold tracking-tight outline-none"
>
{flowTitle}
</h2>
{isDraft ? (
<NoticeSteps pdfOpened={pdfOpened} settings={settingsQuery.data} canManage={canManage} />
) : null}
{notice.status === "SENT" ? <p className="text-sm">{sentText(notice)}</p> : null}
{notice.status === "CANCELLED" ? (
<p className="text-sm text-muted-foreground">Diese Anzeige wurde verworfen. Es wurde nichts gesendet.</p>
) : null}
{canManage && isDraft ? (
<div className="flex flex-wrap gap-2">
{pdfOpened ? null : (
<MarkSentDialog
noticeId={notice.id}
primary={false}
disabled={isOffline}
describedBy={offlineReason}
onDone={(next) => applyNotice(next, "Als gesendet markiert.")}
/>
)}
<Button
variant="outline"
size="touch"
disabled={isOffline || simple.isPending}
aria-describedby={offlineReason}
onClick={() => simple.mutate("recount")}
>
<RotateCcw aria-hidden />
Neu zählen
</Button>
<ConfirmDialog
triggerLabel="Anzeige stornieren"
triggerSize="touch"
triggerDisabled={isOffline}
triggerDescribedBy={offlineReason}
title="Anzeige stornieren?"
description="Der Entwurf wird verworfen. Gesendet wurde nichts. Du kannst danach eine neue Anzeige anlegen."
confirmLabel="Ja, stornieren"
isPending={simple.isPending}
onConfirm={() => simple.mutate("cancel")}
/>
</div>
) : null}
{canManage && notice.status === "SENT" ? (
<div>
<ConfirmDialog
triggerLabel="Wieder öffnen"
triggerSize="touch"
triggerDisabled={isOffline}
triggerDescribedBy={offlineReason}
title="Anzeige wieder öffnen?"
description="Die Anzeige wird wieder ein Entwurf, das Sendedatum entfällt. Danach kannst du sie neu zählen und erneut senden."
confirmLabel="Ja, wieder öffnen"
destructive={false}
isPending={simple.isPending}
onConfirm={() => simple.mutate("reopen")}
/>
</div>
) : null}
</section>
<NoticeHerd notice={notice} />
{canManage && notice.status !== "CANCELLED" ? (
<NoticeEditForm
key={[notice.totalColonies, notice.referenceDate, notice.sentOn, notice.sentVia, notice.reference, notice.notes].join("|")}
notice={notice}
onSaved={(next) => applyNotice(next, "Änderungen gespeichert.")}
/>
) : null}
<DocumentUploadCard
entityType="AUTHORITY_NOTICE"
entityId={notice.id}
title="Kopie und Eingangsbestätigung"
canWrite={canManage}
/>
</main>
);
}
@@ -0,0 +1,100 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { CreateNoticeDialog } from "./create-notice-dialog";
import { ApiError } from "@/lib/auth/api-client";
import { createNotice } from "@/lib/compliance/notices-api";
import { noticeFixture } from "@/lib/compliance/notice-fixtures";
const push = vi.fn();
vi.mock("next/navigation", () => ({ useRouter: () => ({ push }) }));
vi.mock("@/lib/offline/connectivity", () => ({ useConnectivity: () => "online" }));
vi.mock("@/lib/compliance/notices-api", () => ({ createNotice: vi.fn() }));
function renderDialog() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={client}>
<CreateNoticeDialog />
</QueryClientProvider>,
);
}
async function open() {
await userEvent.click(screen.getByRole("button", { name: "Anzeige anlegen" }));
}
describe("CreateNoticeDialog", () => {
beforeEach(() => {
push.mockReset();
vi.mocked(createNotice).mockReset();
});
it('"bereits angezeigt am …" creates a SENT registration with the entered date', async () => {
vi.mocked(createNotice).mockResolvedValue(noticeFixture({ id: "n9", kind: "REGISTRATION", status: "SENT" }));
renderDialog();
await open();
await userEvent.click(screen.getByRole("radio", { name: "Erstanzeige" }));
await userEvent.click(screen.getByRole("checkbox", { name: "Bereits angezeigt" }));
fireEvent.change(screen.getByLabelText("Angezeigt am"), { target: { value: "2026-05-01" } });
await userEvent.click(screen.getByRole("button", { name: "Anlegen" }));
expect(createNotice).toHaveBeenCalledWith(
{ kind: "REGISTRATION", sentOn: "2026-05-01" },
{ idempotencyKey: expect.stringMatching(/^[A-Za-z0-9_-]{16,128}$/) },
);
expect(push).toHaveBeenCalledWith("/compliance/notices/n9");
});
it("a TSK count needs a year and sends it", async () => {
vi.mocked(createNotice).mockResolvedValue(noticeFixture({ id: "n3", kind: "TSK_REPORT", year: 2027 }));
renderDialog();
await open();
await userEvent.click(screen.getByRole("radio", { name: "TSK-Zählung" }));
const year = screen.getByLabelText("Jahr");
await userEvent.clear(year);
await userEvent.click(screen.getByRole("button", { name: "Anlegen" }));
expect(createNotice).not.toHaveBeenCalled();
expect(screen.getByText("Bitte ein Jahr eingeben.")).toBeInTheDocument();
await userEvent.type(year, "2027");
await userEvent.click(screen.getByRole("button", { name: "Anlegen" }));
expect(createNotice).toHaveBeenCalledWith({ kind: "TSK_REPORT", year: 2027 }, expect.any(Object));
});
it("a 409 DRAFT_EXISTS links to the existing draft", async () => {
vi.mocked(createNotice).mockRejectedValue(
new ApiError(409, "Es gibt schon einen Entwurf.", {
code: "DRAFT_EXISTS",
existingId: "n7",
message: "Es gibt schon einen Entwurf.",
}),
);
renderDialog();
await open();
await userEvent.click(screen.getByRole("radio", { name: "Bestandsänderung" }));
await userEvent.click(screen.getByRole("button", { name: "Anlegen" }));
expect(await screen.findByText("Es gibt schon einen Entwurf.")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Zum vorhandenen Entwurf" })).toHaveAttribute(
"href",
"/compliance/notices/n7",
);
expect(push).not.toHaveBeenCalled();
});
it("shows any other server message, for example the missing first registration", async () => {
vi.mocked(createNotice).mockRejectedValue(
new ApiError(409, "Erst die Erstanzeige erfassen.", { message: "Erst die Erstanzeige erfassen." }),
);
renderDialog();
await open();
await userEvent.click(screen.getByRole("radio", { name: "Bestandsänderung" }));
await userEvent.click(screen.getByRole("button", { name: "Anlegen" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Erst die Erstanzeige erfassen.");
});
});
@@ -0,0 +1,221 @@
"use client";
import Link from "next/link";
import { FormEvent, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Plus } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { FormSection } from "@/components/form/form-section";
import { ToggleChip } from "@/components/form/toggle-chip";
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 { complianceErrorText, draftExists } from "@/lib/compliance/errors";
import { NOTICE_KINDS, NOTICE_KIND_LABEL } from "@/lib/compliance/notice-labels";
import type { AuthorityNoticeKind, CreateNoticeInput } from "@/lib/compliance/notice-types";
import { createNotice } from "@/lib/compliance/notices-api";
import { complianceKey } from "@/lib/compliance/use-readiness";
import { useAttemptKey } from "@/lib/medicines/use-attempt-key";
const YEAR_PROBLEM = "Bitte ein Jahr eingeben.";
const SENT_ON_PROBLEM = "Bitte das Datum der Anzeige eingeben.";
const KIND_HINT: Record<AuthorityNoticeKind, string> = {
REGISTRATION: "Deine erste Meldung an das Veterinäramt.",
HERD_CHANGE: "Wenn sich Völker oder Stände geändert haben.",
TSK_REPORT: "Die jährliche Zählung für die Tierseuchenkasse.",
};
interface CreateNoticeDialogProps {
/** Switched off from outside, e.g. offline; `describedBy` names the reason. */
disabled?: boolean;
describedBy?: string;
}
/** „Anzeige anlegen“: one draft per kind and year, or a notice that was sent earlier. */
export function CreateNoticeDialog({ disabled = false, describedBy }: CreateNoticeDialogProps) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button size="touch" disabled={disabled} aria-describedby={describedBy}>
<Plus aria-hidden />
Anzeige anlegen
</Button>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>Anzeige anlegen</DialogTitle>
</DialogHeader>
<CreateNoticeForm onDone={() => setOpen(false)} />
</DialogContent>
</Dialog>
);
}
function problemWith(kind: AuthorityNoticeKind, year: string, alreadySent: boolean, sentOn: string): string | null {
if (kind === "TSK_REPORT" && (year.trim() === "" || !Number.isInteger(Number(year.trim())))) {
return YEAR_PROBLEM;
}
if (alreadySent && sentOn === "") return SENT_ON_PROBLEM;
return null;
}
interface SubmitError {
text: string;
existingId: string | null;
}
function CreateNoticeForm({ onDone }: { onDone: () => void }) {
const router = useRouter();
const queryClient = useQueryClient();
const attempt = useAttemptKey();
const { isOffline, hintId, describedBy } = useOnlineOnly();
const [kind, setKind] = useState<AuthorityNoticeKind>("REGISTRATION");
const [year, setYear] = useState(String(new Date().getFullYear()));
const [alreadySent, setAlreadySent] = useState(false);
const [sentOn, setSentOn] = useState("");
const [referenceDate, setReferenceDate] = useState("");
const [showProblem, setShowProblem] = useState(false);
const [error, setError] = useState<SubmitError | null>(null);
const errorRef = useRef<HTMLDivElement>(null);
const problem = problemWith(kind, year, alreadySent, sentOn);
useEffect(() => {
if (error) errorRef.current?.focus();
}, [error]);
function buildInput(): CreateNoticeInput {
return {
kind,
...(kind === "TSK_REPORT" ? { year: Number(year.trim()) } : {}),
...(alreadySent ? { sentOn } : {}),
...(referenceDate ? { referenceDate } : {}),
};
}
const mutation = useMutation({
mutationFn: () => {
const input = buildInput();
return createNotice(input, { idempotencyKey: attempt.keyFor(input) });
},
onSuccess: (notice) => {
void queryClient.invalidateQueries({ queryKey: complianceKey });
attempt.done();
onDone();
router.push(`/compliance/notices/${encodeURIComponent(notice.id)}`);
},
onError: (mutationError) => {
attempt.failed(mutationError);
const existing = draftExists(mutationError);
setError(
existing
? { text: existing.message || "Es gibt schon einen Entwurf.", existingId: existing.existingId }
: { text: complianceErrorText(mutationError, "Die Anzeige wurde nicht angelegt."), existingId: null },
);
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
setShowProblem(true);
if (problem !== null || isOffline) return;
mutation.mutate();
}
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit} noValidate>
<fieldset className="flex flex-col gap-2">
<legend className="mb-1 text-sm font-medium">Art der Anzeige</legend>
{NOTICE_KINDS.map((entry) => (
<label
key={entry}
className="flex min-h-11 cursor-pointer items-start gap-3 rounded-lg border border-border px-3 py-2 has-[:checked]:border-primary has-[:checked]:bg-primary/10"
>
<input
type="radio"
name="notice-kind"
value={entry}
checked={kind === entry}
onChange={() => setKind(entry)}
aria-labelledby={`notice-kind-${entry}`}
aria-describedby={`notice-kind-hint-${entry}`}
className="mt-1 size-4"
/>
<span className="flex flex-col">
<span id={`notice-kind-${entry}`} className="text-sm font-medium">
{NOTICE_KIND_LABEL[entry]}
</span>
<span id={`notice-kind-hint-${entry}`} className="text-xs text-muted-foreground">
{KIND_HINT[entry]}
</span>
</span>
</label>
))}
</fieldset>
{kind === "TSK_REPORT" ? (
<div className="flex flex-col gap-2">
<Label htmlFor="notice-year">Jahr</Label>
<Input
id="notice-year"
inputMode="numeric"
value={year}
aria-invalid={showProblem && problem === YEAR_PROBLEM}
onChange={(event) => setYear(event.target.value)}
/>
</div>
) : null}
<ToggleChip label="Bereits angezeigt" checked={alreadySent} onChange={setAlreadySent} />
{alreadySent ? (
<div className="flex flex-col gap-2">
<Label htmlFor="notice-sent-on">Angezeigt am</Label>
<Input id="notice-sent-on" type="date" value={sentOn} onChange={(event) => setSentOn(event.target.value)} />
</div>
) : null}
<FormSection title="Weitere Angaben" collapsible defaultOpen={false} fieldCount={1}>
<div className="flex flex-col gap-2">
<Label htmlFor="notice-reference-date">Stichtag</Label>
<Input
id="notice-reference-date"
type="date"
value={referenceDate}
onChange={(event) => setReferenceDate(event.target.value)}
/>
<p className="text-xs text-muted-foreground">Leer lassen: heute.</p>
</div>
</FormSection>
<p aria-live="polite" className="min-h-5 text-sm text-destructive">
{showProblem ? problem : null}
</p>
<div ref={errorRef} tabIndex={-1} role="alert" className="outline-none">
{error ? (
<div className="flex flex-col gap-2 text-sm text-destructive">
<p>{error.text}</p>
{error.existingId ? (
<Link
href={`/compliance/notices/${encodeURIComponent(error.existingId)}`}
className="inline-flex min-h-11 items-center self-start underline underline-offset-2"
>
Zum vorhandenen Entwurf
</Link>
) : null}
</div>
) : null}
</div>
<OnlineOnlyHint id={hintId} />
<Button type="submit" size="touch" disabled={mutation.isPending || isOffline} aria-describedby={describedBy}>
{mutation.isPending ? "Legt an…" : "Anlegen"}
</Button>
</form>
);
}
@@ -0,0 +1,98 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import NoticesPage from "./page";
import { fetchNotices } from "@/lib/compliance/notices-api";
import { noticeFixture } from "@/lib/compliance/notice-fixtures";
let permissions = ["compliance:read", "compliance:manage"];
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ hasPermission: (key: string) => permissions.includes(key) }),
}));
vi.mock("@/lib/offline/connectivity", () => ({ useConnectivity: () => "online" }));
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
vi.mock("@/lib/compliance/notices-api", () => ({ fetchNotices: vi.fn(), createNotice: vi.fn() }));
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<NoticesPage />
</QueryClientProvider>,
);
}
describe("NoticesPage", () => {
beforeEach(() => {
permissions = ["compliance:read", "compliance:manage"];
vi.mocked(fetchNotices).mockReset();
});
it("the list shows kind, year, status, count and reference date as real links", async () => {
vi.mocked(fetchNotices).mockResolvedValue([
noticeFixture({ id: "n1", kind: "TSK_REPORT", year: 2027, status: "SENT", totalColonies: 12, referenceDate: "2027-01-01T00:00:00.000Z" }),
noticeFixture({ id: "n2", kind: "HERD_CHANGE", status: "DRAFT", totalColonies: 7, referenceDate: "2027-03-05T00:00:00.000Z" }),
]);
renderPage();
const first = await screen.findByRole("link", { name: /TSK-Zählung 2027/ });
expect(first).toHaveAttribute("href", "/compliance/notices/n1");
expect(within(first).getByText("Gesendet")).toBeInTheDocument();
expect(first).toHaveTextContent("12 Völker");
expect(first).toHaveTextContent("Stichtag 01.01.2027");
expect(first.querySelector("svg")).not.toBeNull();
const second = screen.getByRole("link", { name: /Bestandsänderung/ });
expect(second).toHaveAttribute("href", "/compliance/notices/n2");
expect(within(second).getByText("Entwurf")).toBeInTheDocument();
expect(second).toHaveTextContent("Stichtag 05.03.2027");
});
it("filters by kind through the server query", async () => {
vi.mocked(fetchNotices).mockResolvedValue([]);
renderPage();
await screen.findByText(/Noch keine Anzeige/);
await userEvent.selectOptions(screen.getByLabelText("Art"), "TSK_REPORT");
expect(fetchNotices).toHaveBeenLastCalledWith({ kind: "TSK_REPORT" });
});
it("filters by year through the server query and flags a year that is not four digits", async () => {
vi.mocked(fetchNotices).mockResolvedValue([]);
renderPage();
await screen.findByText(/Noch keine Anzeige/);
await userEvent.type(screen.getByLabelText("Jahr"), "20");
expect(screen.getByText("Bitte vier Ziffern eingeben, zum Beispiel 2027.")).toBeInTheDocument();
await userEvent.type(screen.getByLabelText("Jahr"), "27");
expect(fetchNotices).toHaveBeenLastCalledWith({ year: 2027 });
expect(screen.queryByText(/Bitte vier Ziffern/)).not.toBeInTheDocument();
});
it("says the list is capped when the server returned the newest 200", async () => {
const many = Array.from({ length: 200 }, (_, index) => noticeFixture({ id: `n${index}` }));
vi.mocked(fetchNotices).mockResolvedValue(many);
renderPage();
expect(await screen.findByText("Es werden die neuesten 200 angezeigt.")).toBeInTheDocument();
});
it("offers Anzeige anlegen only with compliance:manage", async () => {
vi.mocked(fetchNotices).mockResolvedValue([]);
const { unmount } = renderPage();
expect(await screen.findByRole("button", { name: "Anzeige anlegen" })).toBeInTheDocument();
unmount();
permissions = ["compliance:read"];
renderPage();
await screen.findByText(/Noch keine Anzeige/);
expect(screen.queryByRole("button", { name: "Anzeige anlegen" })).not.toBeInTheDocument();
});
it("says so without the read permission and loads nothing", () => {
permissions = [];
renderPage();
expect(screen.getByText("Dazu fehlt dir das Recht.")).toBeInTheDocument();
expect(fetchNotices).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,146 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { BookOpen } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { DetailHeader } from "@/components/detail-header";
import { NoComplianceAccess } from "@/components/compliance/no-compliance-access";
import { NoticeStatus } from "@/components/compliance/notice-status";
import { EmptyState } from "@/components/empty-state";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { QueryState } from "@/components/query-state";
import { useAuth } from "@/lib/auth/auth-context";
import { NOTICE_KINDS, NOTICE_KIND_LABEL, noticeDay, noticeTitle } from "@/lib/compliance/notice-labels";
import type { AuthorityNotice, AuthorityNoticeKind } from "@/lib/compliance/notice-types";
import { fetchNotices } from "@/lib/compliance/notices-api";
import { complianceKey } from "@/lib/compliance/use-readiness";
import { formatDay } from "@/lib/medicines/format";
import { CreateNoticeDialog } from "./create-notice-dialog";
type KindFilter = AuthorityNoticeKind | "ALL";
/** The server lists at most this many, newest first. */
const LIST_CAP = 200;
const YEAR_PATTERN = /^\d{4}$/;
export default function NoticesPage() {
const { hasPermission } = useAuth();
const canRead = hasPermission("compliance:read");
const canManage = hasPermission("compliance:manage");
const { isOffline, hintId, describedBy } = useOnlineOnly();
const [kind, setKind] = useState<KindFilter>("ALL");
const [yearText, setYearText] = useState("");
const year = YEAR_PATTERN.test(yearText.trim()) ? Number(yearText.trim()) : undefined;
const yearInvalid = yearText.trim() !== "" && year === undefined;
const query = useQuery({
queryKey: [...complianceKey, "notices", kind, year ?? "all"],
queryFn: () => fetchNotices({ ...(kind === "ALL" ? {} : { kind }), ...(year === undefined ? {} : { year }) }),
enabled: canRead,
});
if (!canRead) return <NoComplianceAccess />;
return (
<main className="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4 sm:p-6">
<DetailHeader
icon={BookOpen}
title="Anzeigen ans Veterinäramt"
subtitle="Erstanzeige, Änderungen im Bestand und die TSK-Zählung."
backHref="/compliance"
backLabel="Prüfbereitschaft"
primaryAction={canManage ? <CreateNoticeDialog disabled={isOffline} describedBy={describedBy} /> : undefined}
/>
{canManage ? <OnlineOnlyHint id={hintId} /> : null}
<div className="flex flex-wrap gap-4">
<div className="flex flex-col gap-2">
<label htmlFor="notice-kind-filter" className="text-sm font-medium">
Art
</label>
<select
id="notice-kind-filter"
value={kind}
onChange={(event) => setKind(event.target.value as KindFilter)}
className="h-11 w-full rounded-xl border border-input bg-card px-3 text-base sm:max-w-xs md:text-sm"
>
<option value="ALL">Alle</option>
{NOTICE_KINDS.map((entry) => (
<option key={entry} value={entry}>
{NOTICE_KIND_LABEL[entry]}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-2">
<label htmlFor="notice-year-filter" className="text-sm font-medium">
Jahr
</label>
<input
id="notice-year-filter"
inputMode="numeric"
maxLength={4}
value={yearText}
placeholder="alle"
aria-invalid={yearInvalid ? true : undefined}
aria-describedby={yearInvalid ? "notice-year-problem" : undefined}
onChange={(event) => setYearText(event.target.value)}
className="h-11 w-28 rounded-xl border border-input bg-card px-3 text-base md:text-sm"
/>
{yearInvalid ? (
<p id="notice-year-problem" className="text-sm text-destructive">
Bitte vier Ziffern eingeben, zum Beispiel 2027.
</p>
) : null}
</div>
</div>
<div aria-live="polite">
<QueryState
query={query}
what="Die Anzeigen"
empty={
<EmptyState
icon={BookOpen}
title="Noch keine Anzeige"
description="Hier erscheinen deine Anzeigen ans Veterinäramt."
/>
}
>
{(notices) => (
<>
{notices.length >= LIST_CAP ? (
<p className="mb-2 text-sm text-muted-foreground">Es werden die neuesten {LIST_CAP} angezeigt.</p>
) : null}
<ul className="flex flex-col gap-2">
{notices.map((notice) => (
<li key={notice.id}>
<NoticeLink notice={notice} />
</li>
))}
</ul>
</>
)}
</QueryState>
</div>
</main>
);
}
function NoticeLink({ notice }: { notice: AuthorityNotice }) {
return (
<Link
href={`/compliance/notices/${encodeURIComponent(notice.id)}`}
className="flex min-h-11 flex-col gap-1 rounded-xl border border-border/60 bg-card p-3 outline-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50"
>
<span className="flex flex-wrap items-center justify-between gap-2">
<span className="font-heading text-base font-semibold">{noticeTitle(notice)}</span>
<NoticeStatus status={notice.status} />
</span>
<span className="text-sm text-muted-foreground">
{notice.totalColonies} {notice.totalColonies === 1 ? "Volk" : "Völker"} · Stichtag {formatDay(noticeDay(notice.referenceDate))}
{notice.sentOn ? ` · gesendet am ${formatDay(noticeDay(notice.sentOn))}` : ""}
</span>
</Link>
);
}
@@ -168,12 +168,12 @@ describe("CompliancePage", () => {
it("falls back to showing the item when the page that fixes it does not exist yet", async () => { it("falls back to showing the item when the page that fixes it does not exist yet", async () => {
vi.mocked(fetchReadiness).mockResolvedValue( vi.mocked(fetchReadiness).mockResolvedValue(
reportFixture([open({ key: "vet-office", area: "BEHOERDEN", severity: "PFLICHT", subjectId: "global" })]), reportFixture([open({ key: "unknown-rule", area: "BEHOERDEN", severity: "PFLICHT", subjectId: "global" })]),
); );
renderPage(); renderPage();
const main = await screen.findByRole("link", { name: "Nächsten Punkt ansehen" }); const main = await screen.findByRole("link", { name: "Nächsten Punkt ansehen" });
expect(main).toHaveAttribute("href", "#item-vet-office-global"); expect(main).toHaveAttribute("href", "#item-unknown-rule-global");
expect(screen.queryByRole("link", { name: /Beheben/ })).not.toBeInTheDocument(); expect(screen.queryByRole("link", { name: /Beheben/ })).not.toBeInTheDocument();
}); });
}); });
@@ -82,8 +82,14 @@ describe("ReadinessItemCard", () => {
}); });
it("hides Beheben while the target page does not exist", () => { it("hides Beheben while the target page does not exist", () => {
renderCard(itemFixture({ key: "vet-office", area: "BEHOERDEN" })); renderCard(itemFixture({ key: "unknown-rule", area: "BEHOERDEN" }));
expect(screen.queryByRole("link", { name: /Beheben/ })).not.toBeInTheDocument(); expect(screen.queryByRole("link", { name: /Beheben/ })).not.toBeInTheDocument();
}); });
it("links the vet-office item to the settings page, which exists now", () => {
renderCard(itemFixture({ key: "vet-office", area: "BEHOERDEN" }));
expect(screen.getByRole("link", { name: /Beheben/ })).toHaveAttribute("href", "/compliance/settings");
});
}); });
@@ -0,0 +1,131 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fetchApiaries } from "@/lib/apiaries/api";
import { createRestrictions } from "@/lib/compliance/restrictions-api";
import { ApiError } from "@/lib/auth/api-client";
import { CreateRestrictionDialog } from "./create-restriction-dialog";
vi.mock("@/lib/apiaries/api", () => ({ fetchApiaries: vi.fn() }));
vi.mock("@/lib/compliance/restrictions-api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/compliance/restrictions-api")>()),
createRestrictions: vi.fn(),
}));
vi.mock("@/lib/offline/connectivity", () => ({ useConnectivity: () => "online" }));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
function renderDialog() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<CreateRestrictionDialog />
</QueryClientProvider>,
);
}
async function openDialog() {
await userEvent.click(screen.getByRole("button", { name: "Sperrbezirk anlegen" }));
return screen.findByRole("dialog");
}
describe("CreateRestrictionDialog", () => {
beforeEach(() => {
vi.mocked(fetchApiaries).mockResolvedValue([
{ id: "a-1", name: "Obstwiese", status: "ACTIVE" },
{ id: "a-2", name: "Hausgarten", status: "ACTIVE" },
] as never);
vi.mocked(createRestrictions).mockReset();
vi.mocked(createRestrictions).mockResolvedValue([]);
});
it("offers the disease as a ChoiceGroup with free text only for Andere", async () => {
renderDialog();
const dialog = await openDialog();
const group = within(dialog).getByRole("radiogroup", { name: /Seuche/ });
expect(within(group).getAllByRole("radio").map((radio) => radio.textContent)).toEqual([
"Amerikanische Faulbrut",
"Kleiner Beutenkäfer",
"Andere",
]);
expect(within(dialog).queryByLabelText(/Name der Seuche/)).not.toBeInTheDocument();
await userEvent.click(within(group).getByRole("radio", { name: "Andere" }));
expect(within(dialog).getByLabelText(/Name der Seuche/)).toBeInTheDocument();
});
it("creates one restriction request for several apiaries", async () => {
renderDialog();
const dialog = await openDialog();
await userEvent.click(await within(dialog).findByRole("checkbox", { name: "Obstwiese" }));
await userEvent.click(within(dialog).getByRole("checkbox", { name: "Hausgarten" }));
await userEvent.click(within(dialog).getByRole("radio", { name: "Amerikanische Faulbrut" }));
await userEvent.clear(within(dialog).getByLabelText(/Beginn/));
await userEvent.type(within(dialog).getByLabelText(/Beginn/), "2026-10-01");
await userEvent.click(within(dialog).getByRole("button", { name: "Sperrbezirk eintragen" }));
await waitFor(() => expect(createRestrictions).toHaveBeenCalledTimes(1));
const [input, options] = vi.mocked(createRestrictions).mock.calls[0];
expect(input).toEqual({
apiaryIds: ["a-1", "a-2"],
disease: "Amerikanische Faulbrut",
startsOn: "2026-10-01",
});
expect(options?.idempotencyKey).toEqual(expect.any(String));
});
it("sends the free text of Andere as the disease", async () => {
renderDialog();
const dialog = await openDialog();
await userEvent.click(await within(dialog).findByRole("checkbox", { name: "Obstwiese" }));
await userEvent.click(within(dialog).getByRole("radio", { name: "Andere" }));
await userEvent.type(within(dialog).getByLabelText(/Name der Seuche/), "Tropilaelaps");
await userEvent.click(within(dialog).getByRole("button", { name: "Sperrbezirk eintragen" }));
await waitFor(() => expect(createRestrictions).toHaveBeenCalled());
expect(vi.mocked(createRestrictions).mock.calls[0][0]).toMatchObject({
apiaryIds: ["a-1"],
disease: "Tropilaelaps",
});
});
it("asks for apiary and disease at the fields instead of sending", async () => {
renderDialog();
const dialog = await openDialog();
await userEvent.click(within(dialog).getByRole("button", { name: "Sperrbezirk eintragen" }));
expect(within(dialog).getByText("Bitte mindestens einen Bienenstand auswählen.")).toBeInTheDocument();
expect(within(dialog).getByText("Bitte die Seuche auswählen.")).toBeInTheDocument();
expect(createRestrictions).not.toHaveBeenCalled();
});
it("refuses an end day before the start day", async () => {
renderDialog();
const dialog = await openDialog();
await userEvent.click(await within(dialog).findByRole("checkbox", { name: "Obstwiese" }));
await userEvent.click(within(dialog).getByRole("radio", { name: "Kleiner Beutenkäfer" }));
await userEvent.clear(within(dialog).getByLabelText(/Beginn/));
await userEvent.type(within(dialog).getByLabelText(/Beginn/), "2026-10-10");
await userEvent.type(within(dialog).getByLabelText(/Ende/), "2026-10-01");
await userEvent.click(within(dialog).getByRole("button", { name: "Sperrbezirk eintragen" }));
expect(within(dialog).getByText("Das Ende darf nicht vor dem Beginn liegen.")).toBeInTheDocument();
expect(createRestrictions).not.toHaveBeenCalled();
});
it("shows a server error next to the button", async () => {
vi.mocked(createRestrictions).mockRejectedValue(new ApiError(404, "Bienenstand nicht gefunden"));
renderDialog();
const dialog = await openDialog();
await userEvent.click(await within(dialog).findByRole("checkbox", { name: "Obstwiese" }));
await userEvent.click(within(dialog).getByRole("radio", { name: "Amerikanische Faulbrut" }));
await userEvent.click(within(dialog).getByRole("button", { name: "Sperrbezirk eintragen" }));
expect(await within(dialog).findByRole("alert")).toHaveTextContent("Bienenstand nicht gefunden");
});
});
@@ -0,0 +1,312 @@
"use client";
import { FormEvent, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { fetchApiaries } from "@/lib/apiaries/api";
import { complianceErrorText } from "@/lib/compliance/errors";
import {
createRestrictions,
type CreateRestrictionsInput,
} from "@/lib/compliance/restrictions-api";
import { complianceKey } from "@/lib/compliance/use-readiness";
import { berlinCalendarDay } from "@/lib/dates";
import { useAttemptKey } from "@/lib/medicines/use-attempt-key";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { ChoiceGroup, type ChoiceOption } from "@/components/form/choice-group";
import { Field } from "@/components/form/field";
import { FormSection } from "@/components/form/form-section";
import { ToggleChip } from "@/components/form/toggle-chip";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
/** The server takes 1-50 apiaries per request, one restriction each. */
const MAX_APIARIES = 50;
const MAX_TEXT_LENGTH = 200;
const MAX_NOTES_LENGTH = 2000;
const OTHER = "OTHER";
type DiseaseChoice = "Amerikanische Faulbrut" | "Kleiner Beutenkäfer" | typeof OTHER | "";
const DISEASE_OPTIONS: readonly ChoiceOption<DiseaseChoice>[] = [
{ value: "Amerikanische Faulbrut", label: "Amerikanische Faulbrut" },
{ value: "Kleiner Beutenkäfer", label: "Kleiner Beutenkäfer" },
{ value: OTHER, label: "Andere" },
];
interface FormErrors {
apiaryIds?: string;
disease?: string;
otherDisease?: string;
startsOn?: string;
endsOn?: string;
}
interface FormValues {
apiaryIds: string[];
disease: DiseaseChoice;
otherDisease: string;
startsOn: string;
endsOn: string;
authority: string;
reference: string;
notes: string;
}
function emptyValues(): FormValues {
return {
apiaryIds: [],
disease: "",
otherDisease: "",
startsOn: berlinCalendarDay(new Date().toISOString()),
endsOn: "",
authority: "",
reference: "",
notes: "",
};
}
function validate(values: FormValues): FormErrors {
const errors: FormErrors = {};
if (values.apiaryIds.length === 0) errors.apiaryIds = "Bitte mindestens einen Bienenstand auswählen.";
if (values.apiaryIds.length > MAX_APIARIES) {
errors.apiaryIds = `Höchstens ${MAX_APIARIES} Bienenstände auf einmal.`;
}
if (values.disease === "") errors.disease = "Bitte die Seuche auswählen.";
if (values.disease === OTHER && !values.otherDisease.trim()) {
errors.otherDisease = "Bitte den Namen der Seuche angeben.";
}
if (!values.startsOn) errors.startsOn = "Bitte den Beginn angeben.";
if (values.endsOn && values.startsOn && values.endsOn < values.startsOn) {
errors.endsOn = "Das Ende darf nicht vor dem Beginn liegen.";
}
return errors;
}
function optional(text: string): string | undefined {
const trimmed = text.trim();
return trimmed === "" ? undefined : trimmed;
}
function toInput(values: FormValues): CreateRestrictionsInput {
const authority = optional(values.authority);
const reference = optional(values.reference);
const notes = optional(values.notes);
return {
apiaryIds: values.apiaryIds,
disease: values.disease === OTHER ? values.otherDisease.trim() : values.disease,
startsOn: values.startsOn,
...(values.endsOn ? { endsOn: values.endsOn } : {}),
...(authority ? { authority } : {}),
...(reference ? { reference } : {}),
...(notes ? { notes } : {}),
};
}
/** The main action of the restrictions page: one declaration for one or several apiaries. */
export function CreateRestrictionDialog() {
const queryClient = useQueryClient();
const attempt = useAttemptKey();
const { isOffline, hintId, describedBy } = useOnlineOnly();
const [open, setOpen] = useState(false);
const [values, setValues] = useState<FormValues>(emptyValues);
const [errors, setErrors] = useState<FormErrors>({});
const [submitError, setSubmitError] = useState<string | null>(null);
const apiariesQuery = useQuery({ queryKey: ["apiaries"], queryFn: () => fetchApiaries() });
const apiaries = apiariesQuery.data ?? [];
function set<K extends keyof FormValues>(key: K, value: FormValues[K]) {
setValues((current) => ({ ...current, [key]: value }));
setErrors((current) => ({ ...current, [key]: undefined }));
}
function toggleApiary(apiaryId: string, checked: boolean) {
set(
"apiaryIds",
checked ? [...values.apiaryIds, apiaryId] : values.apiaryIds.filter((id) => id !== apiaryId),
);
}
const mutation = useMutation({
mutationFn: (input: CreateRestrictionsInput) =>
createRestrictions(input, { idempotencyKey: attempt.keyFor(input) }),
onSuccess: (rows) => {
attempt.done();
void queryClient.invalidateQueries({ queryKey: complianceKey });
toast.success(rows.length === 1 ? "Sperrbezirk eingetragen" : `${rows.length} Sperrbezirke eingetragen`);
setValues(emptyValues());
setOpen(false);
},
onError: (error) => {
attempt.failed(error);
setSubmitError(complianceErrorText(error, "Der Sperrbezirk konnte nicht eingetragen werden."));
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitError(null);
const found = validate(values);
setErrors(found);
if (Object.keys(found).length > 0) return;
mutation.mutate(toInput(values));
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="touch">Sperrbezirk anlegen</Button>} />
<DialogContent>
<DialogHeader>
<DialogTitle>Sperrbezirk anlegen</DialogTitle>
<DialogDescription>
Trag ein, welche Bienenstände das Veterinäramt zum Sperrbezirk erklärt hat. Für jeden Stand
entsteht ein eigener Eintrag.
</DialogDescription>
</DialogHeader>
<form className="flex max-h-[70vh] flex-col gap-4 overflow-y-auto" onSubmit={handleSubmit} noValidate>
<Field
label="Betroffene Bienenstände"
group
required
error={errors.apiaryIds}
hint={`Alle antippen, die im Sperrbezirk liegen (höchstens ${MAX_APIARIES}).`}
>
{({ id, labelId, describedBy: fieldDescribedBy }) => (
<div
id={id}
role="group"
aria-labelledby={labelId}
aria-describedby={fieldDescribedBy}
className="grid grid-cols-1 gap-2 sm:grid-cols-2"
>
{apiaries.map((apiary) => (
<ToggleChip
key={apiary.id}
label={apiary.name}
checked={values.apiaryIds.includes(apiary.id)}
onChange={(checked) => toggleApiary(apiary.id, checked)}
/>
))}
</div>
)}
</Field>
<Field label="Seuche" group required error={errors.disease}>
{({ id, labelId, describedBy: fieldDescribedBy }) => (
<ChoiceGroup
id={id}
labelledBy={labelId}
describedBy={fieldDescribedBy}
options={DISEASE_OPTIONS}
value={values.disease}
onChange={(value) => set("disease", value)}
className="grid-cols-1 sm:grid-cols-3"
/>
)}
</Field>
{values.disease === OTHER ? (
<Field label="Name der Seuche" required error={errors.otherDisease}>
{({ id, describedBy: fieldDescribedBy, invalid }) => (
<Input
id={id}
className="h-11"
maxLength={MAX_TEXT_LENGTH}
aria-describedby={fieldDescribedBy}
aria-invalid={invalid}
value={values.otherDisease}
onChange={(event) => set("otherDisease", event.target.value)}
/>
)}
</Field>
) : null}
<Field label="Beginn" required error={errors.startsOn} hint="Erster Tag der Sperre.">
{({ id, describedBy: fieldDescribedBy, invalid }) => (
<Input
id={id}
type="date"
className="h-11"
aria-describedby={fieldDescribedBy}
aria-invalid={invalid}
value={values.startsOn}
onChange={(event) => set("startsOn", event.target.value)}
/>
)}
</Field>
<Field label="Ende" error={errors.endsOn} hint="Letzter Tag der Sperre. Leer = bis auf Weiteres.">
{({ id, describedBy: fieldDescribedBy, invalid }) => (
<Input
id={id}
type="date"
className="h-11"
aria-describedby={fieldDescribedBy}
aria-invalid={invalid}
value={values.endsOn}
onChange={(event) => set("endsOn", event.target.value)}
/>
)}
</Field>
<FormSection title="Weitere Angaben" collapsible defaultOpen={false} fieldCount={3}>
<Field label="Behörde">
{({ id }) => (
<Input
id={id}
className="h-11"
maxLength={MAX_TEXT_LENGTH}
value={values.authority}
onChange={(event) => set("authority", event.target.value)}
/>
)}
</Field>
<Field label="Aktenzeichen">
{({ id }) => (
<Input
id={id}
className="h-11"
maxLength={MAX_TEXT_LENGTH}
value={values.reference}
onChange={(event) => set("reference", event.target.value)}
/>
)}
</Field>
<Field label="Notizen">
{({ id }) => (
<Textarea
id={id}
maxLength={MAX_NOTES_LENGTH}
value={values.notes}
onChange={(event) => set("notes", event.target.value)}
/>
)}
</Field>
</FormSection>
<DialogFooter className="flex-col sm:flex-col sm:items-stretch">
{submitError ? (
<p role="alert" className="text-sm font-medium text-destructive">
{submitError}
</p>
) : null}
<OnlineOnlyHint id={hintId} />
<Button
type="submit"
size="touch"
disabled={isOffline || mutation.isPending}
aria-describedby={describedBy}
>
{mutation.isPending ? "Wird eingetragen…" : "Sperrbezirk eintragen"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,142 @@
"use client";
import { FormEvent, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { formatDay } from "@/lib/medicines/format";
import { complianceErrorText } from "@/lib/compliance/errors";
import { endRestriction, type ApiaryRestriction } from "@/lib/compliance/restrictions-api";
import { complianceKey } from "@/lib/compliance/use-readiness";
import { berlinCalendarDay, toCalendarDay } from "@/lib/dates";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { Field } from "@/components/form/field";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
/**
* Yesterday (Berlin day), but not before the start: the end day is the last
* day the restriction still holds, so "lifted today" means "until yesterday".
*/
function defaultEndDay(startsOn: string): string {
const today = berlinCalendarDay(new Date().toISOString());
const yesterday = new Date(Date.parse(`${today}T00:00:00Z`) - MS_PER_DAY).toISOString().slice(0, 10);
return yesterday < startsOn ? startsOn : yesterday;
}
/** The restriction still holds on the end day (inclusive), so today or later is not "lifted". */
function stillHoldsText(endsOn: string): string {
return `Die Sperre gilt noch bis einschließlich ${formatDay(endsOn)}.`;
}
/** Ending sets the last day; the entry stays in the list as "Beendet". */
export function EndRestrictionDialog({
restriction,
apiaryName,
}: {
restriction: ApiaryRestriction;
apiaryName: string;
}) {
const queryClient = useQueryClient();
const { isOffline, hintId, describedBy } = useOnlineOnly();
const startsOn = toCalendarDay(restriction.startsOn);
const [open, setOpen] = useState(false);
const [endsOn, setEndsOn] = useState(() => defaultEndDay(startsOn));
const [error, setError] = useState<string | null>(null);
const today = berlinCalendarDay(new Date().toISOString());
const stillHolds = endsOn !== "" && endsOn >= today;
const mutation = useMutation({
mutationFn: () => endRestriction(restriction.id, endsOn),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: complianceKey });
toast.success(stillHolds ? stillHoldsText(endsOn) : "Sperrbezirk beendet");
setOpen(false);
},
onError: (mutationError) =>
setError(complianceErrorText(mutationError, "Der Sperrbezirk konnte nicht beendet werden.")),
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
if (!endsOn) {
setError("Bitte den letzten Tag angeben.");
return;
}
if (endsOn < startsOn) {
setError("Das Ende darf nicht vor dem Beginn liegen.");
return;
}
mutation.mutate();
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button variant="outline" size="touch" aria-label={`Beenden: ${apiaryName}`}>
Beenden
</Button>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>Sperrbezirk beenden</DialogTitle>
<DialogDescription>
{`${apiaryName}: Der Eintrag bleibt erhalten und steht danach unter „Beendet“.`}
</DialogDescription>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit} noValidate>
<Field
label="Letzter Tag der Sperre"
required
hint={
stillHolds
? stillHoldsText(endsOn)
: "An diesem Tag gilt die Sperre noch. Ist sie heute aufgehoben, trag gestern ein."
}
>
{({ id, describedBy: fieldDescribedBy }) => (
<Input
id={id}
type="date"
className="h-11"
min={startsOn}
aria-describedby={fieldDescribedBy}
value={endsOn}
onChange={(event) => setEndsOn(event.target.value)}
/>
)}
</Field>
<DialogFooter className="flex-col sm:flex-col sm:items-stretch">
{error ? (
<p role="alert" className="text-sm font-medium text-destructive">
{error}
</p>
) : null}
<OnlineOnlyHint id={hintId} />
<Button
type="submit"
size="touch"
disabled={isOffline || mutation.isPending}
aria-describedby={describedBy}
>
{mutation.isPending ? "Wird beendet…" : "Sperrbezirk beenden"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,193 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { toast } from "sonner";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fetchApiaries } from "@/lib/apiaries/api";
import {
endRestriction,
fetchRestrictions,
type ApiaryRestriction,
} from "@/lib/compliance/restrictions-api";
import RestrictionsPage from "./page";
let permissions = ["compliance:read", "compliance:manage"];
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ hasPermission: (key: string) => permissions.includes(key) }),
}));
vi.mock("@/lib/offline/connectivity", () => ({ useConnectivity: () => "online" }));
vi.mock("@/lib/apiaries/api", () => ({ fetchApiaries: vi.fn() }));
vi.mock("@/lib/compliance/restrictions-api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/compliance/restrictions-api")>()),
fetchRestrictions: vi.fn(),
createRestrictions: vi.fn(),
endRestriction: vi.fn(),
}));
vi.mock("@/components/document-upload-card", () => ({
DocumentUploadCard: ({
entityType,
entityId,
canWrite,
}: {
entityType: string;
entityId: string;
canWrite?: boolean;
}) => (
<p>
Dokumente {entityType} {entityId} {canWrite ? "schreibend" : "lesend"}
</p>
),
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
function row(overrides: Partial<ApiaryRestriction>): ApiaryRestriction {
return {
id: "r-1",
apiaryId: "a-1",
disease: "Amerikanische Faulbrut",
authority: "Veterinäramt Musterstadt",
reference: "AZ 12/34",
startsOn: "2020-10-01T00:00:00.000Z",
endsOn: null,
notes: null,
...overrides,
};
}
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<RestrictionsPage />
</QueryClientProvider>,
);
}
describe("RestrictionsPage", () => {
beforeEach(() => {
permissions = ["compliance:read", "compliance:manage"];
vi.mocked(fetchApiaries).mockResolvedValue([
{ id: "a-1", name: "Obstwiese", status: "ACTIVE" },
{ id: "a-2", name: "Hausgarten", status: "ACTIVE" },
] as never);
vi.mocked(fetchRestrictions).mockResolvedValue([
row({ id: "r-1" }),
row({ id: "r-2", apiaryId: "a-2", endsOn: "2020-11-01T00:00:00.000Z" }),
]);
vi.mocked(fetchRestrictions).mockClear();
vi.mocked(endRestriction).mockReset();
vi.mocked(endRestriction).mockResolvedValue(row({}));
});
it("lists active and ended restrictions apart, each with word and symbol", async () => {
renderPage();
const active = await screen.findByRole("region", { name: /Aktiv/ });
const ended = screen.getByRole("region", { name: /Beendet/ });
expect(within(active).getByText("Obstwiese")).toBeInTheDocument();
expect(within(active).getByText(/seit 01\.10\.2020/)).toBeInTheDocument();
expect(within(active).getByText(/bis auf Weiteres/)).toBeInTheDocument();
expect(within(active).getByText("Aktiv", { selector: "[data-slot=badge]" })).toBeInTheDocument();
expect(within(ended).getByText("Hausgarten")).toBeInTheDocument();
expect(within(ended).getByText(/bis 01\.11\.2020/)).toBeInTheDocument();
expect(within(ended).getByText("Beendet", { selector: "[data-slot=badge]" })).toBeInTheDocument();
expect(within(ended).queryByRole("button", { name: /Beenden/ })).not.toBeInTheDocument();
});
it("ends a restriction by setting endsOn and keeps the entry", async () => {
renderPage();
const active = await screen.findByRole("region", { name: /Aktiv/ });
await userEvent.click(within(active).getByRole("button", { name: /Beenden/ }));
const dialog = await screen.findByRole("dialog");
await userEvent.clear(within(dialog).getByLabelText(/Letzter Tag/));
await userEvent.type(within(dialog).getByLabelText(/Letzter Tag/), "2026-11-30");
await userEvent.click(within(dialog).getByRole("button", { name: "Sperrbezirk beenden" }));
await waitFor(() => expect(endRestriction).toHaveBeenCalledWith("r-1", "2026-11-30"));
expect(screen.getByText("Obstwiese")).toBeInTheDocument();
});
it("proposes yesterday as the last day, but never before the start", async () => {
renderPage();
const active = await screen.findByRole("region", { name: /Aktiv/ });
await userEvent.click(within(active).getByRole("button", { name: /Beenden/ }));
const dialog = await screen.findByRole("dialog");
const field = within(dialog).getByLabelText(/Letzter Tag/) as HTMLInputElement;
const yesterday = new Date(Date.now() - 36 * 60 * 60 * 1000).toISOString().slice(0, 10);
expect(field.value >= "2020-10-01").toBe(true);
expect(field.value <= new Date().toISOString().slice(0, 10)).toBe(true);
expect(field.value >= yesterday).toBe(true);
});
it("opens the Verfügung upload for APIARY_RESTRICTION", async () => {
renderPage();
const active = await screen.findByRole("region", { name: /Aktiv/ });
await userEvent.click(within(active).getByRole("button", { name: /Verfügung/ }));
expect(screen.getByText("Dokumente APIARY_RESTRICTION r-1 schreibend")).toBeInTheDocument();
});
it("is read only without compliance:manage", async () => {
permissions = ["compliance:read"];
renderPage();
const active = await screen.findByRole("region", { name: /Aktiv/ });
expect(screen.queryByRole("button", { name: "Sperrbezirk anlegen" })).not.toBeInTheDocument();
expect(within(active).queryByRole("button", { name: /Beenden/ })).not.toBeInTheDocument();
await userEvent.click(within(active).getByRole("button", { name: /Verfügung/ }));
expect(screen.getByText("Dokumente APIARY_RESTRICTION r-1 lesend")).toBeInTheDocument();
});
it("offers the creation as the one main action with compliance:manage", async () => {
renderPage();
expect(await screen.findByRole("button", { name: "Sperrbezirk anlegen" })).toBeInTheDocument();
});
it("shows a future restriction as Geplant with clock and word, not under Beendet", async () => {
vi.mocked(fetchRestrictions).mockResolvedValue([
row({ id: "r-3", startsOn: "2999-01-01T00:00:00.000Z" }),
]);
renderPage();
const planned = await screen.findByRole("region", { name: /Geplant/ });
expect(within(planned).getByText("Geplant", { selector: "[data-slot=badge]" }).querySelector("svg")).not.toBeNull();
expect(screen.queryByRole("region", { name: /Beendet/ })).not.toBeInTheDocument();
expect(screen.getByText("Aktuell ist kein Stand im Sperrbezirk.")).toBeInTheDocument();
});
it("says the restriction still holds when the end day is today or later", async () => {
renderPage();
const active = await screen.findByRole("region", { name: /Aktiv/ });
await userEvent.click(within(active).getByRole("button", { name: /Beenden/ }));
const dialog = await screen.findByRole("dialog");
fireEvent.change(within(dialog).getByLabelText(/Letzter Tag/), { target: { value: "2999-02-03" } });
expect(within(dialog).getByText("Die Sperre gilt noch bis einschließlich 03.02.2999.")).toBeInTheDocument();
await userEvent.click(within(dialog).getByRole("button", { name: "Sperrbezirk beenden" }));
await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Die Sperre gilt noch bis einschließlich 03.02.2999."));
});
it("keeps the live region for loading and error only", async () => {
const { container } = renderPage();
await screen.findByRole("region", { name: /Aktiv/ });
const live = container.querySelector("[aria-live=polite]");
expect(live).not.toBeNull();
expect(live?.querySelector("section")).toBeNull();
});
it("shows the missing-right text instead of the page without compliance:read", async () => {
permissions = [];
renderPage();
expect(await screen.findByText("Dazu fehlt dir das Recht.")).toBeInTheDocument();
expect(fetchRestrictions).not.toHaveBeenCalled();
});
it("says so when there are no restrictions", async () => {
vi.mocked(fetchRestrictions).mockResolvedValue([]);
renderPage();
expect(await screen.findByText("Aktuell ist kein Stand im Sperrbezirk.")).toBeInTheDocument();
});
});
@@ -0,0 +1,153 @@
"use client";
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { OctagonAlert } from "lucide-react";
import { NoComplianceAccess } from "@/components/compliance/no-compliance-access";
import { DetailHeader } from "@/components/detail-header";
import { StandNote } from "@/components/offline/stand-note";
import { ErrorState, LoadingState } from "@/components/query-state";
import { fetchApiaries } from "@/lib/apiaries/api";
import { useAuth } from "@/lib/auth/auth-context";
import {
fetchRestrictions,
restrictionState,
type ApiaryRestriction,
type RestrictionState,
} from "@/lib/compliance/restrictions-api";
import { complianceKey } from "@/lib/compliance/use-readiness";
import { berlinCalendarDay } from "@/lib/dates";
import { CreateRestrictionDialog } from "./create-restriction-dialog";
import { RestrictionRow } from "./restriction-row";
const restrictionListKey = [...complianceKey, "restrictions", "list"] as const;
const UNKNOWN_APIARY = "Unbekannter Stand";
function Section({
id,
title,
rows,
emptyText,
nameOf,
state,
canManage,
canRead,
}: {
id: string;
title: string;
rows: ApiaryRestriction[];
emptyText: string;
nameOf: (apiaryId: string) => string;
state: RestrictionState;
canManage: boolean;
canRead: boolean;
}) {
const headingId = `restrictions-${id}`;
return (
<section aria-labelledby={headingId} className="flex flex-col gap-2">
<h2 id={headingId} className="font-heading text-lg font-semibold tracking-tight">
{`${title} (${rows.length})`}
</h2>
{rows.length === 0 ? (
<p className="text-sm text-muted-foreground">{emptyText}</p>
) : (
<ul className="flex flex-col gap-2">
{rows.map((row) => (
<RestrictionRow
key={row.id}
restriction={row}
apiaryName={nameOf(row.apiaryId)}
state={state}
canManage={canManage}
canRead={canRead}
/>
))}
</ul>
)}
</section>
);
}
export default function RestrictionsPage() {
const { hasPermission } = useAuth();
const canManage = hasPermission("compliance:manage");
const canRead = hasPermission("compliance:read");
const query = useQuery({ queryKey: restrictionListKey, queryFn: fetchRestrictions, enabled: canRead });
const apiariesQuery = useQuery({ queryKey: ["apiaries"], queryFn: () => fetchApiaries(), enabled: canRead });
const data = query.data;
const names = useMemo(
() => new Map((apiariesQuery.data ?? []).map((apiary) => [apiary.id, apiary.name])),
[apiariesQuery.data],
);
const { active, planned, ended } = useMemo(() => {
// The Berlin calendar day, as on the server.
const today = berlinCalendarDay(new Date().toISOString());
const rows = data ?? [];
const by = (state: RestrictionState) => rows.filter((row) => restrictionState(row, today) === state);
return { active: by("active"), planned: by("planned"), ended: by("ended") };
}, [data]);
const nameOf = (apiaryId: string) => names.get(apiaryId) ?? UNKNOWN_APIARY;
if (!canRead) return <NoComplianceAccess />;
return (
<main className="mx-auto flex w-full max-w-3xl flex-col gap-6 p-4 sm:p-6">
<DetailHeader
icon={OctagonAlert}
title="Sperrbezirke"
subtitle="Stände, die das Veterinäramt zum Sperrbezirk erklärt hat."
backHref="/compliance"
backLabel="Prüfbereitschaft"
primaryAction={canManage ? <CreateRestrictionDialog /> : undefined}
/>
<StandNote updatedAt={query.dataUpdatedAt} isStale={query.isError && data !== undefined} />
<div aria-live="polite">
{query.isLoading ? <LoadingState what="Die Sperrbezirke" /> : null}
{!query.isLoading && data === undefined ? (
<ErrorState what="Die Sperrbezirke" onRetry={() => void query.refetch()} isRetrying={query.isFetching} />
) : null}
</div>
<div className="flex flex-col gap-6">
{data ? (
<>
<Section
id="active"
title="Aktiv"
rows={active}
emptyText="Aktuell ist kein Stand im Sperrbezirk."
nameOf={nameOf}
state="active"
canManage={canManage}
canRead={canRead}
/>
{planned.length > 0 ? (
<Section
id="planned"
title="Geplant"
rows={planned}
emptyText=""
nameOf={nameOf}
state="planned"
canManage={canManage}
canRead={canRead}
/>
) : null}
{ended.length > 0 ? (
<Section
id="ended"
title="Beendet"
rows={ended}
emptyText=""
nameOf={nameOf}
state="ended"
canManage={canManage}
canRead={canRead}
/>
) : null}
</>
) : null}
</div>
</main>
);
}
@@ -0,0 +1,91 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { CheckCircle2, Clock, FileText, OctagonAlert } from "lucide-react";
import { DocumentUploadCard } from "@/components/document-upload-card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import type { ApiaryRestriction, RestrictionState } from "@/lib/compliance/restrictions-api";
import { toCalendarDay } from "@/lib/dates";
import { formatDay } from "@/lib/medicines/format";
import { EndRestrictionDialog } from "./end-restriction-dialog";
interface RestrictionRowProps {
restriction: ApiaryRestriction;
apiaryName: string;
state: RestrictionState;
canManage: boolean;
canRead: boolean;
}
function periodOf(restriction: ApiaryRestriction): string {
const since = `seit ${formatDay(toCalendarDay(restriction.startsOn))}`;
return restriction.endsOn === null
? `${since} · bis auf Weiteres`
: `${since} · bis ${formatDay(toCalendarDay(restriction.endsOn))}`;
}
/** One restriction: state as word and symbol, the Verfügung behind a button. */
export function RestrictionRow({ restriction, apiaryName, state, canManage, canRead }: RestrictionRowProps) {
const [showDocuments, setShowDocuments] = useState(false);
const details = [restriction.authority, restriction.reference].filter(Boolean).join(" · ");
return (
<li
data-restriction-id={restriction.id}
className="flex flex-col gap-2 rounded-xl border border-border/60 bg-card p-3"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="font-heading text-base font-semibold">
<Link href={`/apiaries/${encodeURIComponent(restriction.apiaryId)}`} className="underline-offset-4 hover:underline">
{apiaryName}
</Link>
</h3>
{state === "active" ? (
<Badge variant="destructive" className="h-6 gap-1.5 px-2.5 text-sm">
<OctagonAlert aria-hidden />
Aktiv
</Badge>
) : state === "planned" ? (
<Badge variant="outline" className="h-6 gap-1.5 px-2.5 text-sm">
<Clock aria-hidden />
Geplant
</Badge>
) : (
<Badge variant="secondary" className="h-6 gap-1.5 px-2.5 text-sm">
<CheckCircle2 aria-hidden />
Beendet
</Badge>
)}
</div>
<p className="text-sm font-medium">{restriction.disease}</p>
<p className="text-sm text-muted-foreground">{periodOf(restriction)}</p>
{details ? <p className="text-sm text-muted-foreground">{details}</p> : null}
{restriction.notes ? <p className="text-sm text-muted-foreground">{restriction.notes}</p> : null}
<div className="flex flex-wrap gap-2">
{canRead ? (
<Button
type="button"
variant="outline"
size="touch"
aria-expanded={showDocuments}
onClick={() => setShowDocuments((current) => !current)}
>
<FileText aria-hidden />
Verfügung
</Button>
) : null}
{state !== "ended" && canManage ? <EndRestrictionDialog restriction={restriction} apiaryName={apiaryName} /> : null}
</div>
{canRead && showDocuments ? (
<DocumentUploadCard
entityType="APIARY_RESTRICTION"
entityId={restriction.id}
title="Verfügung"
canWrite={canManage}
/>
) : null}
</li>
);
}
@@ -0,0 +1,164 @@
"use client";
import { Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { parseDecimal } from "@/lib/medicines/format";
import type { NetQuantityDigitRule } from "@/lib/compliance/notice-types";
/** A rule as typed: German decimals stay text until the form is saved. */
export interface DigitRuleText {
maxGrams: string;
minMm: string;
}
export const DIGIT_RULES_PROBLEM = "Bitte die Ziffernhöhen unter „Erweitert“ prüfen.";
/** Bounds of the backend (compliance-settings.service.ts): 1 to 10 rules, 0.5 to 20 mm. */
export const MIN_DIGIT_RULES = 1;
export const MAX_DIGIT_RULES = 10;
const MIN_DIGIT_MM = 0.5;
const MAX_DIGIT_MM = 20;
export const HEIGHT_PROBLEM = "Die Ziffernhöhe muss zwischen 0,5 und 20 mm liegen.";
export const LIMIT_PROBLEM = "Die Obergrenzen müssen größer als 0 sein und aufsteigen.";
function numberText(value: number | null): string {
return value === null ? "" : String(value).replace(".", ",");
}
export function digitRulesToText(rules: readonly NetQuantityDigitRule[]): DigitRuleText[] {
return rules.map((rule) => ({ maxGrams: numberText(rule.maxGrams), minMm: numberText(rule.minMm) }));
}
/**
* Per-row messages, mirroring the backend's checks: the height range, limits
* that are above 0 and ascend, and a last row that has no limit (the editor
* always shows the last row without one). An empty result means valid.
*/
export function digitRuleProblems(rows: readonly DigitRuleText[]): Record<number, string> {
const problems: Record<number, string> = {};
let previous = 0;
rows.forEach((row, index) => {
const minMm = parseDecimal(row.minMm);
const heightOk = Number.isFinite(minMm) && minMm >= MIN_DIGIT_MM && minMm <= MAX_DIGIT_MM;
let limitOk = true;
if (index < rows.length - 1) {
const maxGrams = parseDecimal(row.maxGrams);
limitOk = Number.isFinite(maxGrams) && maxGrams > previous;
if (limitOk) previous = maxGrams;
}
if (!heightOk) problems[index] = HEIGHT_PROBLEM;
else if (!limitOk) problems[index] = LIMIT_PROBLEM;
});
return problems;
}
/** The typed rules as numbers; call only when `digitRuleProblems` is empty. The last row has no limit. */
export function digitRulesFromText(rows: readonly DigitRuleText[]): NetQuantityDigitRule[] {
return rows.map((row, index) => ({
maxGrams: index === rows.length - 1 ? null : parseDecimal(row.maxGrams),
minMm: parseDecimal(row.minMm),
}));
}
interface DigitRulesFieldProps {
rows: DigitRuleText[];
readOnly: boolean;
noteId: string;
problems: Record<number, string>;
onChange: (rows: DigitRuleText[]) => void;
}
/** Minimum digit height of the net quantity per jar size; the values are settings, not code. */
export function DigitRulesField({ rows, readOnly, noteId, problems, onChange }: DigitRulesFieldProps) {
function setRow(index: number, patch: Partial<DigitRuleText>) {
onChange(rows.map((row, position) => (position === index ? { ...row, ...patch } : row)));
}
// A new rule goes before the last one, which stays the one without a limit.
function addRow() {
onChange([...rows.slice(0, -1), { maxGrams: "", minMm: "" }, rows[rows.length - 1]]);
}
function removeRow(index: number) {
onChange(rows.filter((_, position) => position !== index));
}
return (
<div className="flex flex-col gap-3">
<p className="text-sm font-medium">Mindesthöhe der Ziffern</p>
<p id={noteId} className="text-xs text-muted-foreground">
Annahme – bitte prüfen
</p>
<ul className="flex flex-col gap-3" aria-describedby={noteId}>
{rows.map((row, index) => {
const isLast = index === rows.length - 1;
const previous = index > 0 ? rows[index - 1].maxGrams : "";
return (
<li key={index} className="grid grid-cols-2 gap-3 rounded-lg bg-muted/50 p-3">
<div className="flex flex-col gap-2">
<Label htmlFor={`digit-max-${index}`}>
{isLast ? `Über ${previous || "…"} g` : "Bis (g)"}
</Label>
<Input
id={`digit-max-${index}`}
inputMode="decimal"
value={isLast ? "" : row.maxGrams}
placeholder={isLast ? "alles darüber" : undefined}
readOnly={readOnly || isLast}
aria-invalid={problems[index] ? true : undefined}
aria-describedby={problems[index] ? `digit-problem-${index}` : undefined}
onChange={(event) => setRow(index, { maxGrams: event.target.value })}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={`digit-min-${index}`}>Mindestens (mm)</Label>
<Input
id={`digit-min-${index}`}
inputMode="decimal"
value={row.minMm}
readOnly={readOnly}
onChange={(event) => setRow(index, { minMm: event.target.value })}
/>
</div>
{problems[index] ? (
<p id={`digit-problem-${index}`} role="alert" className="col-span-2 text-sm text-destructive">
{problems[index]}
</p>
) : null}
{!readOnly ? (
<Button
type="button"
variant="outline"
size="touch"
className="col-span-2 justify-self-start"
disabled={rows.length <= MIN_DIGIT_RULES}
aria-label={`Entfernen, Regel ${index + 1}`}
onClick={() => removeRow(index)}
>
<Trash2 aria-hidden />
Entfernen
</Button>
) : null}
</li>
);
})}
</ul>
{!readOnly ? (
<Button
type="button"
variant="outline"
size="touch"
className="self-start"
disabled={rows.length >= MAX_DIGIT_RULES}
onClick={addRow}
>
<Plus aria-hidden />
Regel hinzufügen
</Button>
) : null}
</div>
);
}
@@ -0,0 +1,232 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import ComplianceSettingsPage from "./page";
import { ApiError } from "@/lib/auth/api-client";
import { settingsFixture } from "@/lib/compliance/notice-fixtures";
import { fetchComplianceSettings, updateComplianceSettings } from "@/lib/compliance/settings-api";
const ASSUMPTION = "Annahme – bitte prüfen";
let permissions = ["compliance:read", "compliance:manage"];
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ hasPermission: (key: string) => permissions.includes(key) }),
}));
vi.mock("@/lib/offline/connectivity", () => ({ useConnectivity: () => "online" }));
vi.mock("@/lib/compliance/settings-api", () => ({
fetchComplianceSettings: vi.fn(),
updateComplianceSettings: vi.fn(),
}));
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return {
client,
...render(
<QueryClientProvider client={client}>
<ComplianceSettingsPage />
</QueryClientProvider>,
),
};
}
describe("ComplianceSettingsPage", () => {
beforeEach(() => {
permissions = ["compliance:read", "compliance:manage"];
vi.mocked(fetchComplianceSettings).mockReset();
vi.mocked(updateComplianceSettings).mockReset();
vi.mocked(fetchComplianceSettings).mockResolvedValue(settingsFixture());
});
it('shows the note "Annahme – bitte prüfen" at the trigger switches, the reference day and the due day', async () => {
renderPage();
const referenceDay = await screen.findByLabelText("Stichtag (MM-TT)");
expect(referenceDay).toHaveAccessibleDescription(new RegExp(ASSUMPTION));
expect(screen.getByLabelText("Meldefrist (MM-TT)")).toHaveAccessibleDescription(new RegExp(ASSUMPTION));
expect(screen.getByRole("checkbox", { name: "Bei geänderter Völkerzahl" })).toHaveAccessibleDescription(
new RegExp(ASSUMPTION),
);
expect(screen.getByRole("checkbox", { name: "Bei geändertem Stand" })).toHaveAccessibleDescription(
new RegExp(ASSUMPTION),
);
// A visible note, not only a tooltip.
expect(screen.getAllByText(ASSUMPTION).length).toBeGreaterThanOrEqual(4);
});
it("keeps counted colony types and digit heights collapsed under Erweitert", async () => {
renderPage();
await screen.findByLabelText("Stichtag (MM-TT)");
expect(screen.queryByText("Gezählte Völkerarten")).not.toBeInTheDocument();
expect(screen.queryByText("Mindesthöhe der Ziffern")).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: /Erweitert/ }));
expect(screen.getByText("Gezählte Völkerarten")).toBeInTheDocument();
expect(screen.getByRole("checkbox", { name: "Wirtschaftsvolk" })).toHaveAttribute("aria-checked", "true");
expect(screen.getByText("Mindesthöhe der Ziffern")).toBeInTheDocument();
});
it("saves the counted types as the excluded ones", async () => {
vi.mocked(updateComplianceSettings).mockResolvedValue(settingsFixture());
renderPage();
await screen.findByLabelText("Stichtag (MM-TT)");
await userEvent.click(screen.getByRole("button", { name: /Erweitert/ }));
await userEvent.click(screen.getByRole("checkbox", { name: "Ableger" }));
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(updateComplianceSettings).toHaveBeenCalledWith(
expect.objectContaining({ tskExcludedColonyTypes: ["NUCLEUS_COLONY"], tskReportDueDay: null }),
);
});
it.each(["02-29", "04-31", "13-01", "1-1"])("an impossible reference day %s is stopped at the field", async (day) => {
renderPage();
const referenceDay = await screen.findByLabelText("Stichtag (MM-TT)");
await userEvent.clear(referenceDay);
await userEvent.type(referenceDay, day);
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Bitte einen Tag im Format MM-TT eingeben");
expect(referenceDay).toHaveAccessibleDescription(/Bitte einen Tag im Format MM-TT/);
expect(updateComplianceSettings).not.toHaveBeenCalled();
});
it("checks the due day the same way but lets it stay empty", async () => {
vi.mocked(updateComplianceSettings).mockResolvedValue(settingsFixture());
renderPage();
const due = await screen.findByLabelText("Meldefrist (MM-TT)");
await userEvent.type(due, "02-30");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(await screen.findByRole("alert")).toHaveTextContent("MM-TT");
expect(updateComplianceSettings).not.toHaveBeenCalled();
await userEvent.clear(due);
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(updateComplianceSettings).toHaveBeenCalledWith(expect.objectContaining({ tskReportDueDay: null }));
});
it("shows the backend's array message in German when the server still rejects", async () => {
const message = ["tskMemberNumber ist zu lang"];
vi.mocked(updateComplianceSettings).mockRejectedValue(new ApiError(400, "x", { message }));
renderPage();
await screen.findByLabelText("Stichtag (MM-TT)");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(await screen.findByRole("alert")).toHaveTextContent("tskMemberNumber ist zu lang");
});
it("shows the server message of a plain 400", async () => {
const text = "Die Obergrenzen der Regeln müssen aufsteigen, nur die letzte hat keine";
vi.mocked(updateComplianceSettings).mockRejectedValue(new ApiError(400, text, { message: text }));
renderPage();
await screen.findByLabelText("Stichtag (MM-TT)");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(await screen.findByRole("alert")).toHaveTextContent(text);
});
describe("digit rules", () => {
async function openRules() {
renderPage();
await screen.findByLabelText("Stichtag (MM-TT)");
await userEvent.click(screen.getByRole("button", { name: /Erweitert/ }));
}
it("adds a rule before the last one and removes one, between 1 and 10", async () => {
await openRules();
expect(screen.getAllByRole("button", { name: /^Entfernen/ })).toHaveLength(2);
await userEvent.click(screen.getByRole("button", { name: "Regel hinzufügen" }));
expect(screen.getAllByRole("button", { name: /^Entfernen/ })).toHaveLength(3);
// The last row still has no limit.
expect(document.getElementById("digit-max-2")).toHaveAttribute("readonly");
await userEvent.click(screen.getByRole("button", { name: "Entfernen, Regel 1" }));
await userEvent.click(screen.getByRole("button", { name: "Entfernen, Regel 1" }));
expect(screen.getByRole("button", { name: "Entfernen, Regel 1" })).toBeDisabled();
});
it("stops at ten rules", async () => {
await openRules();
for (let count = 2; count < 10; count += 1) {
await userEvent.click(screen.getByRole("button", { name: "Regel hinzufügen" }));
}
expect(screen.getByRole("button", { name: "Regel hinzufügen" })).toBeDisabled();
});
it("shows a message at the row for a height outside the backend range and for limits that do not ascend", async () => {
await openRules();
const first = document.getElementById("digit-min-0") as HTMLElement;
await userEvent.clear(first);
await userEvent.type(first, "25");
await userEvent.click(screen.getByRole("button", { name: "Regel hinzufügen" }));
await userEvent.type(document.getElementById("digit-min-1") as HTMLElement, "1");
await userEvent.type(document.getElementById("digit-max-1") as HTMLElement, "50");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
const row = (index: number) => document.getElementById(`digit-min-${index}`)?.closest("li") as HTMLElement;
expect(within(row(0)).getByRole("alert")).toHaveTextContent("zwischen 0,5 und 20 mm");
expect(within(row(1)).getByRole("alert")).toHaveTextContent("Obergrenzen");
expect(updateComplianceSettings).not.toHaveBeenCalled();
});
it("sends valid rules with the last one unlimited", async () => {
vi.mocked(updateComplianceSettings).mockResolvedValue(settingsFixture());
await openRules();
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(updateComplianceSettings).toHaveBeenCalledWith(
expect.objectContaining({
netQuantityDigitRules: [
{ maxGrams: 100, minMm: 1.5 },
{ maxGrams: null, minMm: 2.5 },
],
}),
);
});
});
it("keeps unsaved input on a background refetch and offers to reload", async () => {
const { client } = renderPage();
const name = await screen.findByLabelText("Name des Veterinäramts");
await userEvent.clear(name);
await userEvent.type(name, "Mein Amt");
act(() => {
client.setQueryData(["compliance", "settings"], settingsFixture({ registrationNumber: "R-1" }));
});
expect(await screen.findByText("Daten wurden geändert – neu laden?")).toBeInTheDocument();
expect(screen.getByLabelText("Name des Veterinäramts")).toHaveValue("Mein Amt");
await userEvent.click(screen.getByRole("button", { name: "Neu laden" }));
expect(screen.getByLabelText("Name des Veterinäramts")).toHaveValue("Veterinäramt Musterstadt");
expect(screen.getByLabelText("Registriernummer")).toHaveValue("R-1");
});
it("takes over new server data silently while nothing was typed", async () => {
const { client } = renderPage();
await screen.findByLabelText("Name des Veterinäramts");
act(() => {
client.setQueryData(["compliance", "settings"], settingsFixture({ registrationNumber: "R-2" }));
});
expect(await screen.findByDisplayValue("R-2")).toBeInTheDocument();
expect(screen.queryByText("Daten wurden geändert – neu laden?")).not.toBeInTheDocument();
});
it("STAFF sees the settings read-only without a save button", async () => {
permissions = ["compliance:read"];
renderPage();
const referenceDay = await screen.findByLabelText("Stichtag (MM-TT)");
expect(referenceDay).toHaveAttribute("readonly");
expect(screen.getByRole("checkbox", { name: "Bei geänderter Völkerzahl" })).toBeDisabled();
expect(screen.queryByRole("button", { name: "Speichern" })).not.toBeInTheDocument();
});
it("says so without the read permission and loads nothing", () => {
permissions = [];
renderPage();
expect(screen.getByText("Dazu fehlt dir das Recht.")).toBeInTheDocument();
expect(fetchComplianceSettings).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,367 @@
"use client";
import { FormEvent, useState } from "react";
import { Settings } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { NoComplianceAccess } from "@/components/compliance/no-compliance-access";
import { DetailHeader, DetailQueryFallback } from "@/components/detail-header";
import { FormSection } from "@/components/form/form-section";
import { ToggleChip } from "@/components/form/toggle-chip";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
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 { useAuth } from "@/lib/auth/auth-context";
import { COLONY_TYPES, COLONY_TYPE_LABELS } from "@/lib/colonies/labels";
import type { ColonyType } from "@/lib/colonies/types";
import { describeSaveError } from "@/lib/honey-batches/errors";
import { complianceErrorText } from "@/lib/compliance/errors";
import { MONTH_DAY_PROBLEM, isValidMonthDay } from "@/lib/compliance/month-day";
import type { ComplianceSettings } from "@/lib/compliance/notice-types";
import { fetchComplianceSettings, updateComplianceSettings } from "@/lib/compliance/settings-api";
import { complianceKey } from "@/lib/compliance/use-readiness";
import {
DIGIT_RULES_PROBLEM,
DigitRulesField,
type DigitRuleText,
digitRuleProblems,
digitRulesFromText,
digitRulesToText,
} from "./digit-rules-field";
const ASSUMPTION = "Annahme – bitte prüfen";
interface FormValues {
veterinaryOfficeName: string;
veterinaryOfficeAddress: string;
registrationNumber: string;
noticeOnColonyCountChange: boolean;
noticeOnApiaryChange: boolean;
tskReferenceDay: string;
tskReportDueDay: string;
tskMemberNumber: string;
/** The types that are counted; the server stores the excluded ones. */
counted: ColonyType[];
rules: DigitRuleText[];
}
function valuesOf(settings: ComplianceSettings): FormValues {
return {
veterinaryOfficeName: settings.veterinaryOfficeName ?? "",
veterinaryOfficeAddress: settings.veterinaryOfficeAddress ?? "",
registrationNumber: settings.registrationNumber ?? "",
noticeOnColonyCountChange: settings.noticeOnColonyCountChange,
noticeOnApiaryChange: settings.noticeOnApiaryChange,
tskReferenceDay: settings.tskReferenceDay,
tskReportDueDay: settings.tskReportDueDay ?? "",
tskMemberNumber: settings.tskMemberNumber ?? "",
counted: COLONY_TYPES.filter((type) => !settings.tskExcludedColonyTypes.includes(type)),
rules: digitRulesToText(settings.netQuantityDigitRules),
};
}
function textOrNull(value: string): string | null {
const trimmed = value.trim();
return trimmed === "" ? null : trimmed;
}
/** The request body; call only when the form passed `problemsOf`. */
function bodyOf(values: FormValues): Partial<ComplianceSettings> {
const rules = digitRulesFromText(values.rules);
return {
veterinaryOfficeName: textOrNull(values.veterinaryOfficeName),
veterinaryOfficeAddress: textOrNull(values.veterinaryOfficeAddress),
registrationNumber: textOrNull(values.registrationNumber),
noticeOnColonyCountChange: values.noticeOnColonyCountChange,
noticeOnApiaryChange: values.noticeOnApiaryChange,
tskReferenceDay: values.tskReferenceDay.trim(),
tskReportDueDay: textOrNull(values.tskReportDueDay),
tskMemberNumber: textOrNull(values.tskMemberNumber),
tskExcludedColonyTypes: COLONY_TYPES.filter((type) => !values.counted.includes(type)),
netQuantityDigitRules: rules,
};
}
/** Client-side checks that mirror the backend, so a wrong day is caught at the field. */
function problemsOf(values: FormValues): {
fields: Record<string, string>;
rules: Record<number, string>;
} {
const fields: Record<string, string> = {};
if (!isValidMonthDay(values.tskReferenceDay.trim())) fields.tskReferenceDay = MONTH_DAY_PROBLEM;
const due = values.tskReportDueDay.trim();
if (due !== "" && !isValidMonthDay(due)) fields.tskReportDueDay = MONTH_DAY_PROBLEM;
return { fields, rules: digitRuleProblems(values.rules) };
}
/** The visible note at a field whose value is an assumption the owner must check. */
function AssumptionNote({ id }: { id: string }) {
return (
<p id={id} className="text-xs text-muted-foreground">
{ASSUMPTION}
</p>
);
}
export default function ComplianceSettingsPage() {
const { hasPermission } = useAuth();
const canRead = hasPermission("compliance:read");
const query = useQuery({
queryKey: [...complianceKey, "settings"],
queryFn: fetchComplianceSettings,
enabled: canRead,
});
if (!canRead) return <NoComplianceAccess />;
if (query.isLoading || query.isError || !query.data) {
return <DetailQueryFallback query={query} what="Die Einstellungen" backHref="/compliance" backLabel="Prüfbereitschaft" />;
}
return <SettingsForm settings={query.data} canManage={hasPermission("compliance:manage")} />;
}
function SettingsForm({ settings, canManage }: { settings: ComplianceSettings; canManage: boolean }) {
const queryClient = useQueryClient();
const { isOffline, hintId, describedBy } = useOnlineOnly();
const [loaded, setLoaded] = useState(settings);
const [values, setValues] = useState<FormValues>(() => valuesOf(settings));
const [ruleProblems, setRuleProblems] = useState<Record<number, string>>({});
const [general, setGeneral] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [saved, setSaved] = useState("");
const readOnly = !canManage;
// A refetch must not wipe what is being typed: with unsaved input the person
// is asked, without it the new data is taken over.
const serverChanged = JSON.stringify(settings) !== JSON.stringify(loaded);
const isDirty = JSON.stringify(values) !== JSON.stringify(valuesOf(loaded));
if (serverChanged && !isDirty) {
setLoaded(settings);
setValues(valuesOf(settings));
}
function reload() {
setLoaded(settings);
setValues(valuesOf(settings));
setRuleProblems({});
setFieldErrors({});
setGeneral(null);
}
const mutation = useMutation({
mutationFn: (body: Partial<ComplianceSettings>) => updateComplianceSettings(body),
onSuccess: (next) => {
setLoaded(next);
setValues(valuesOf(next));
queryClient.setQueryData([...complianceKey, "settings"], next);
void queryClient.invalidateQueries({ queryKey: complianceKey });
setSaved("Gespeichert.");
},
onError: (error) => {
const fallback = "Die Einstellungen wurden nicht gespeichert.";
const { fields } = describeSaveError(error, fallback);
setGeneral(Object.keys(fields).length > 0 ? null : complianceErrorText(error, fallback));
setFieldErrors(fields);
},
});
function set<K extends keyof FormValues>(key: K, value: FormValues[K]) {
setValues((current) => ({ ...current, [key]: value }));
}
function toggleCounted(type: ColonyType, checked: boolean) {
set(
"counted",
COLONY_TYPES.filter((entry) => (entry === type ? checked : values.counted.includes(entry))),
);
}
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setGeneral(null);
setFieldErrors({});
setSaved("");
const problems = problemsOf(values);
setRuleProblems(problems.rules);
setFieldErrors(problems.fields);
if (Object.keys(problems.rules).length > 0) setGeneral(DIGIT_RULES_PROBLEM);
if (Object.keys(problems.fields).length > 0 || Object.keys(problems.rules).length > 0) return;
if (isOffline) return;
mutation.mutate(bodyOf(values));
}
const fieldError = (name: string) => fieldErrors[name];
const describedByFor = (...ids: string[]) => ids.join(" ");
return (
<main className="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4 sm:p-6">
<DetailHeader
icon={Settings}
title="Einstellungen Behörden"
subtitle={readOnly ? "Du kannst die Einstellungen nur ansehen." : "Hier legst du fest, wann und wohin du meldest."}
backHref="/compliance"
backLabel="Prüfbereitschaft"
/>
{serverChanged && isDirty ? (
<div role="status" className="flex flex-wrap items-center gap-3 rounded-xl border border-border bg-muted/50 p-3 text-sm">
<span>Daten wurden geändert – neu laden?</span>
<Button type="button" variant="outline" size="touch" onClick={reload}>
Neu laden
</Button>
</div>
) : null}
<form className="flex flex-col gap-6" onSubmit={handleSubmit} noValidate>
<FormSection title="Veterinäramt" step={1}>
<div className="flex flex-col gap-2">
<Label htmlFor="vet-name">Name des Veterinäramts</Label>
<Input
id="vet-name"
value={values.veterinaryOfficeName}
maxLength={200}
readOnly={readOnly}
onChange={(event) => set("veterinaryOfficeName", event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="vet-address">Anschrift des Veterinäramts</Label>
<Textarea
id="vet-address"
value={values.veterinaryOfficeAddress}
maxLength={300}
readOnly={readOnly}
onChange={(event) => set("veterinaryOfficeAddress", event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="registration-number">Registriernummer</Label>
<Input
id="registration-number"
value={values.registrationNumber}
maxLength={200}
readOnly={readOnly}
onChange={(event) => set("registrationNumber", event.target.value)}
/>
</div>
</FormSection>
<FormSection title="Wann soll ich dich an eine Anzeige erinnern?" step={2}>
<div className="flex flex-col gap-1">
<ToggleChip
label="Bei geänderter Völkerzahl"
checked={values.noticeOnColonyCountChange}
disabled={readOnly}
describedBy="note-trigger-count"
onChange={(checked) => set("noticeOnColonyCountChange", checked)}
/>
<AssumptionNote id="note-trigger-count" />
</div>
<div className="flex flex-col gap-1">
<ToggleChip
label="Bei geändertem Stand"
checked={values.noticeOnApiaryChange}
disabled={readOnly}
describedBy="note-trigger-apiary"
onChange={(checked) => set("noticeOnApiaryChange", checked)}
/>
<AssumptionNote id="note-trigger-apiary" />
</div>
</FormSection>
<FormSection title="Tierseuchenkasse (TSK)" step={3}>
<div className="flex flex-col gap-2">
<Label htmlFor="tsk-reference-day">Stichtag (MM-TT)</Label>
<Input
id="tsk-reference-day"
value={values.tskReferenceDay}
placeholder="01-01"
maxLength={5}
readOnly={readOnly}
aria-invalid={fieldError("tskReferenceDay") ? true : undefined}
aria-describedby={describedByFor("note-reference-day", ...(fieldError("tskReferenceDay") ? ["error-reference-day"] : []))}
onChange={(event) => set("tskReferenceDay", event.target.value)}
/>
<AssumptionNote id="note-reference-day" />
{fieldError("tskReferenceDay") ? (
<p id="error-reference-day" role="alert" className="text-sm text-destructive">
{fieldError("tskReferenceDay")}
</p>
) : null}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="tsk-due-day">Meldefrist (MM-TT)</Label>
<Input
id="tsk-due-day"
value={values.tskReportDueDay}
placeholder="leer = keine Frist"
maxLength={5}
readOnly={readOnly}
aria-invalid={fieldError("tskReportDueDay") ? true : undefined}
aria-describedby={describedByFor("note-due-day", ...(fieldError("tskReportDueDay") ? ["error-due-day"] : []))}
onChange={(event) => set("tskReportDueDay", event.target.value)}
/>
<AssumptionNote id="note-due-day" />
{fieldError("tskReportDueDay") ? (
<p id="error-due-day" role="alert" className="text-sm text-destructive">
{fieldError("tskReportDueDay")}
</p>
) : null}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="tsk-member">TSK-Mitgliedsnummer</Label>
<Input
id="tsk-member"
value={values.tskMemberNumber}
maxLength={200}
readOnly={readOnly}
onChange={(event) => set("tskMemberNumber", event.target.value)}
/>
</div>
</FormSection>
<FormSection title="Erweitert" collapsible defaultOpen={false} fieldCount={2}>
<fieldset className="flex flex-col gap-2">
<legend className="mb-1 text-sm font-medium">Gezählte Völkerarten</legend>
<p className="text-xs text-muted-foreground">Nur diese Arten zählen in der TSK-Zählung mit.</p>
<div className="flex flex-wrap gap-2">
{COLONY_TYPES.map((type) => (
<ToggleChip
key={type}
label={COLONY_TYPE_LABELS[type]}
checked={values.counted.includes(type)}
disabled={readOnly}
onChange={(checked) => toggleCounted(type, checked)}
/>
))}
</div>
</fieldset>
<DigitRulesField
rows={values.rules}
readOnly={readOnly}
noteId="note-digit-rules"
problems={ruleProblems}
onChange={(rules) => set("rules", rules)}
/>
</FormSection>
<div aria-live="assertive">
{general ? (
<p role="alert" className="text-sm font-medium text-destructive">
{general}
</p>
) : null}
</div>
<p role="status" aria-live="polite" className="min-h-5 text-sm font-medium">
{saved}
</p>
{canManage ? (
<>
<OnlineOnlyHint id={hintId} />
<Button type="submit" size="touch" disabled={mutation.isPending || isOffline} aria-describedby={describedBy}>
{mutation.isPending ? "Speichert…" : "Speichern"}
</Button>
</>
) : null}
</form>
</main>
);
}
@@ -0,0 +1,87 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fetchApiaries } from "@/lib/apiaries/api";
import { fetchColonies } from "@/lib/colonies/api";
import { updateHarvest } from "@/lib/harvests/api";
import { fetchActiveRestrictions } from "@/lib/compliance/restrictions-api";
import type { Harvest } from "@/lib/harvests/types";
import { HarvestEditDialog } from "./harvest-edit-dialog";
vi.mock("@/lib/apiaries/api", () => ({ fetchApiaries: vi.fn() }));
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
vi.mock("@/lib/harvests/api", () => ({ updateHarvest: vi.fn() }));
vi.mock("@/lib/compliance/restrictions-api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/compliance/restrictions-api")>()),
fetchActiveRestrictions: vi.fn(),
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
const harvest = {
id: "h-1",
apiaryId: "apiary-1",
userId: "u-1",
date: "2026-08-01T00:00:00.000Z",
honeyType: null,
honeySuperCount: null,
grossWeight: null,
netWeight: null,
notes: null,
createdAt: "",
colonies: [{ id: "x", colonyId: "colony-1", colony: { id: "colony-1", code: "V-01" } }],
} as Harvest;
function renderDialog() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<HarvestEditDialog harvest={harvest} />
</QueryClientProvider>,
);
}
describe("HarvestEditDialog – Sperrbezirk", () => {
beforeEach(() => {
vi.mocked(fetchApiaries).mockResolvedValue([
{ id: "apiary-1", name: "Garten" },
{ id: "apiary-2", name: "Obstwiese" },
] as never);
vi.mocked(fetchColonies).mockResolvedValue([
{ id: "colony-1", code: "V-01", status: "ACTIVE", apiaryId: "apiary-1" },
] as never);
vi.mocked(updateHarvest).mockReset();
vi.mocked(updateHarvest).mockResolvedValue(harvest);
});
it("warns about Auflagen for honey and combs when the apiary is restricted, and still saves", async () => {
vi.mocked(fetchActiveRestrictions).mockResolvedValue([
{
id: "r-1",
apiaryId: "apiary-1",
apiaryName: "Garten",
disease: "Amerikanische Faulbrut",
startsOn: "2026-10-01",
},
]);
renderDialog();
await userEvent.click(screen.getByRole("button", { name: /Bearbeiten/ }));
const dialog = await screen.findByRole("dialog");
expect(
await within(dialog).findByText("Im Sperrbezirk gelten Auflagen auch für Honig und Waben. Verfügung prüfen."),
).toBeInTheDocument();
await userEvent.click(within(dialog).getByRole("button", { name: "Speichern" }));
expect(updateHarvest).toHaveBeenCalledTimes(1);
});
it("shows no warning for a free apiary", async () => {
vi.mocked(fetchActiveRestrictions).mockResolvedValue([]);
renderDialog();
await userEvent.click(screen.getByRole("button", { name: /Bearbeiten/ }));
const dialog = await screen.findByRole("dialog");
await vi.waitFor(() => expect(fetchActiveRestrictions).toHaveBeenCalled());
expect(within(dialog).queryByText(/Im Sperrbezirk gelten Auflagen/)).not.toBeInTheDocument();
});
});
@@ -22,6 +22,7 @@ import {
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { toApiDate } from "@/lib/dates"; import { toApiDate } from "@/lib/dates";
import { HarvestRestrictionWarning } from "@/components/compliance/restriction-notice";
export function HarvestEditDialog({ harvest }: { harvest: Harvest }) { export function HarvestEditDialog({ harvest }: { harvest: Harvest }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -117,6 +118,7 @@ export function HarvestEditDialog({ harvest }: { harvest: Harvest }) {
getLabel={(apiary) => apiary.name} getLabel={(apiary) => apiary.name}
placeholder="Bienenstand wählen" placeholder="Bienenstand wählen"
/> />
<HarvestRestrictionWarning apiaryId={apiaryId} />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Beteiligte Völker</Label> <Label>Beteiligte Völker</Label>
@@ -6,6 +6,7 @@ import NewHarvestPage from "./page";
import { createHarvest } from "@/lib/harvests/api"; import { createHarvest } from "@/lib/harvests/api";
import { fetchColonies } from "@/lib/colonies/api"; import { fetchColonies } from "@/lib/colonies/api";
import { fetchApiaries } from "@/lib/apiaries/api"; import { fetchApiaries } from "@/lib/apiaries/api";
import { fetchActiveRestrictions } from "@/lib/compliance/restrictions-api";
import { fetchWithdrawalStatus } from "@/lib/treatments/api"; import { fetchWithdrawalStatus } from "@/lib/treatments/api";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -15,6 +16,10 @@ vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams("apiaryId=apiary-1"), useSearchParams: () => new URLSearchParams("apiaryId=apiary-1"),
})); }));
vi.mock("@/lib/apiaries/api", () => ({ fetchApiaries: vi.fn() })); vi.mock("@/lib/apiaries/api", () => ({ fetchApiaries: vi.fn() }));
vi.mock("@/lib/compliance/restrictions-api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/compliance/restrictions-api")>()),
fetchActiveRestrictions: vi.fn().mockResolvedValue([]),
}));
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() })); vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
vi.mock("@/lib/treatments/api", () => ({ fetchWithdrawalStatus: vi.fn() })); vi.mock("@/lib/treatments/api", () => ({ fetchWithdrawalStatus: vi.fn() }));
vi.mock("@/lib/harvests/api", () => ({ createHarvest: vi.fn() })); vi.mock("@/lib/harvests/api", () => ({ createHarvest: vi.fn() }));
@@ -171,3 +176,43 @@ describe("NewHarvestPage", () => {
expect(push).toHaveBeenCalledWith("/harvests/h-1"); expect(push).toHaveBeenCalledWith("/harvests/h-1");
}); });
}); });
describe("NewHarvestPage – Sperrbezirk", () => {
beforeEach(() => {
window.localStorage.clear();
push.mockReset();
vi.mocked(createHarvest).mockReset();
vi.mocked(createHarvest).mockResolvedValue({ id: "h-1" } as never);
});
it("warns that Auflagen apply to honey and combs - and still saves", async () => {
vi.mocked(fetchActiveRestrictions).mockResolvedValue([
{
id: "r-1",
apiaryId: "apiary-1",
apiaryName: "Garten",
disease: "Amerikanische Faulbrut",
startsOn: "2026-10-01",
},
]);
renderPage();
expect(
await screen.findByText("Im Sperrbezirk gelten Auflagen auch für Honig und Waben. Verfügung prüfen."),
).toBeInTheDocument();
await userEvent.click(await screen.findByRole("checkbox", { name: "V-01" }));
const save = screen.getByRole("button", { name: "Ernte speichern" });
expect(save).toBeEnabled();
await userEvent.click(save);
expect(createHarvest).toHaveBeenCalledTimes(1);
});
it("shows no warning for a free apiary", async () => {
vi.mocked(fetchActiveRestrictions).mockResolvedValue([]);
renderPage();
await screen.findByRole("checkbox", { name: "V-01" });
expect(screen.queryByText(/Im Sperrbezirk gelten Auflagen/)).not.toBeInTheDocument();
});
});
@@ -28,6 +28,7 @@ import { FormSection } from "@/components/form/form-section";
import { ToggleChip } from "@/components/form/toggle-chip"; import { ToggleChip } from "@/components/form/toggle-chip";
import { StickySubmit } from "@/components/form/sticky-submit"; import { StickySubmit } from "@/components/form/sticky-submit";
import { DraftBanner } from "@/components/form/draft-banner"; import { DraftBanner } from "@/components/form/draft-banner";
import { HarvestRestrictionWarning } from "@/components/compliance/restriction-notice";
import { import {
HarvestFormValues, HarvestFormValues,
initialHarvestValues, initialHarvestValues,
@@ -212,6 +213,7 @@ export default function NewHarvestPage() {
/> />
)} )}
</Field> </Field>
<HarvestRestrictionWarning apiaryId={values.apiaryId} />
<Field label="Datum" required error={fieldErrors.date}> <Field label="Datum" required error={fieldErrors.date}>
{({ id, describedBy, invalid }) => ( {({ id, describedBy, invalid }) => (
<Input <Input
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, onTestFinished, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, onTestFinished, describe, expect, it, vi } from "vitest";
import { act, render, screen, waitFor } from "@testing-library/react"; import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider, onlineManager } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider, onlineManager } from "@tanstack/react-query";
import NewTreatmentPage from "./page"; import NewTreatmentPage from "./page";
@@ -11,6 +11,10 @@ import { refreshPacksAfterSync } from "@/lib/apiaries/offline-pack";
import { fetchApiaries } from "@/lib/apiaries/api"; import { fetchApiaries } from "@/lib/apiaries/api";
import { fetchColonies } from "@/lib/colonies/api"; import { fetchColonies } from "@/lib/colonies/api";
import { ApiError } from "@/lib/auth/api-client"; import { ApiError } from "@/lib/auth/api-client";
import { fetchMedicines, fetchSelectableLots } from "@/lib/medicines/api";
import { lot, medicine } from "@/lib/medicines/fixtures.spec-helper";
import { fetchApiaryWeather } from "@/lib/weather/api";
import { todayLocalIsoDate } from "@/lib/dates";
const push = vi.fn(); const push = vi.fn();
vi.mock("next/navigation", () => ({ vi.mock("next/navigation", () => ({
@@ -32,7 +36,16 @@ vi.mock("@/lib/apiaries/offline-pack", async (importOriginal) => ({
listPacks: vi.fn().mockResolvedValue([]), listPacks: vi.fn().mockResolvedValue([]),
refreshPacksAfterSync: vi.fn().mockResolvedValue(undefined), refreshPacksAfterSync: vi.fn().mockResolvedValue(undefined),
})); }));
vi.mock("@/lib/auth/auth-context", () => ({ useCurrentUserId: () => "user-1" })); const granted = vi.hoisted(() => new Set<string>());
vi.mock("@/lib/auth/auth-context", () => ({
useCurrentUserId: () => "user-1",
useAuth: () => ({ hasPermission: (permission: string) => granted.has(permission) }),
}));
vi.mock("@/lib/medicines/api", () => ({
fetchMedicines: vi.fn(),
fetchSelectableLots: vi.fn(),
}));
vi.mock("@/lib/weather/api", () => ({ fetchApiaryWeather: vi.fn() }));
vi.mock("@/lib/offline/db", () => ({ queueTreatment: vi.fn(), queueBulk: vi.fn() })); vi.mock("@/lib/offline/db", () => ({ queueTreatment: vi.fn(), queueBulk: vi.fn() }));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), info: vi.fn(), error: vi.fn() } })); vi.mock("sonner", () => ({ toast: { success: vi.fn(), info: vi.fn(), error: vi.fn() } }));
@@ -72,6 +85,10 @@ describe("NewTreatmentPage", () => {
.mockReset() .mockReset()
.mockResolvedValue([{ id: "apiary-1", name: "Waldrand" }] as never); .mockResolvedValue([{ id: "apiary-1", name: "Waldrand" }] as never);
vi.mocked(fetchTreatmentPrefill).mockReset().mockResolvedValue(null); vi.mocked(fetchTreatmentPrefill).mockReset().mockResolvedValue(null);
granted.clear();
vi.mocked(fetchMedicines).mockReset().mockResolvedValue([]);
vi.mocked(fetchSelectableLots).mockReset().mockResolvedValue([]);
vi.mocked(fetchApiaryWeather).mockReset().mockResolvedValue({ daily: [] });
}); });
afterEach(() => vi.restoreAllMocks()); afterEach(() => vi.restoreAllMocks());
@@ -502,4 +519,441 @@ describe("NewTreatmentPage", () => {
expect(keys[2]).not.toBe(keys[0]); expect(keys[2]).not.toBe(keys[0]);
}); });
}); });
describe("with a medicine from the catalogue", () => {
const OXUVAR = medicine({
id: "m-oxuvar",
name: "Oxuvar",
unit: "ML",
activeIngredient: "Oxalsäure",
withdrawalPeriodDays: 0,
applicationMethod: "Träufeln",
dosePerColony: 30,
minTemperatureC: 12,
maxTemperatureC: 25,
});
const APIVAR = medicine({ id: "m-apivar", name: "Apivar", unit: "PIECE", withdrawalPeriodDays: null });
const OLD_LOT = lot({
id: "l-old",
medicineId: "m-oxuvar",
unit: "ML",
batchNumber: "4711",
remaining: 180,
expiresOn: "2027-03-31",
supplier: "Tierarzt Muster",
purchaseProof: "Rechnung 4711",
});
const NEW_LOT = lot({
id: "l-new",
medicineId: "m-oxuvar",
unit: "ML",
batchNumber: "4800",
remaining: 500,
expiresOn: "2028-01-31",
});
const SAVE = { name: "Behandlung speichern" };
const SOURCE_LINE = "aus Packung: Ch.-B. 4711 · Lieferant Tierarzt Muster · Beleg Rechnung 4711";
const PRESELECT_NOTE = "Die Packung mit dem frühesten Verfall ist vorgewählt.";
const TWO_COLONIES = [
{ id: "colony-1", code: "V-01", status: "ACTIVE", apiaryId: "apiary-1" },
{ id: "colony-2", code: "V-02", status: "WEAK", apiaryId: "apiary-1" },
];
beforeEach(() => {
granted.add("veterinary-medicines:read");
vi.mocked(fetchMedicines).mockResolvedValue([OXUVAR, APIVAR]);
vi.mocked(fetchSelectableLots).mockResolvedValue([OLD_LOT, NEW_LOT]);
});
async function chooseMedicine(name: string) {
await userEvent.click(screen.getByRole("combobox", { name: /Aus dem Katalog/ }));
await userEvent.click(await screen.findByRole("option", { name }));
}
async function chooseBulkApiary() {
vi.mocked(fetchColonies).mockResolvedValue(TWO_COLONIES as never);
await userEvent.click(screen.getByRole("radio", { name: "Mehrere Völker" }));
await userEvent.click(screen.getByRole("combobox", { name: /Stand/ }));
await userEvent.click(await screen.findByRole("option", { name: "Waldrand" }));
await screen.findByText("2 von 2 Völkern gewählt");
}
it("offers the catalogue medicines and the last option „Anderes Mittel“ with the free-text field as today", async () => {
renderPage();
expect(screen.getByLabelText(/^Mittel/)).toBeInTheDocument();
await userEvent.click(screen.getByRole("combobox", { name: /Aus dem Katalog/ }));
const names = (await screen.findAllByRole("option")).map((option) => option.textContent);
expect(names).toEqual(["Oxuvar", "Apivar", "Anderes Mittel"]);
expect(screen.getByLabelText(/^Mittel/)).toBeInTheDocument();
});
it("shows only „Anderes Mittel“ without veterinary-medicines:read and raises no error", async () => {
granted.clear();
renderPage();
await userEvent.click(screen.getByRole("combobox", { name: /Aus dem Katalog/ }));
expect((await screen.findAllByRole("option")).map((option) => option.textContent)).toEqual([
"Anderes Mittel",
]);
expect(fetchMedicines).not.toHaveBeenCalled();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(screen.queryByText(/konnten nicht geladen werden/)).not.toBeInTheDocument();
});
it("says so and keeps „Anderes Mittel“ when the catalogue cannot be loaded", async () => {
vi.mocked(fetchMedicines).mockRejectedValue(new ApiError(500, "Fehler"));
vi.mocked(createTreatment).mockResolvedValue({ id: "t-1", colonyId: "colony-1" } as never);
renderPage();
expect(await screen.findByText(/konnten nicht geladen werden/)).toBeInTheDocument();
await userEvent.type(screen.getByLabelText(/^Mittel/), "Oxuvar");
await userEvent.click(screen.getByRole("button", SAVE));
await waitFor(() => expect(createTreatment).toHaveBeenCalledTimes(1));
});
it("a catalogue medicine prefills ingredient, withdrawal, method and dose with the note „aus Katalog“", async () => {
renderPage();
await chooseMedicine("Oxuvar");
expect(screen.getByLabelText(/Wirkstoff/)).toHaveValue("Oxalsäure");
expect(screen.getByLabelText(/Wartezeit/)).toHaveValue(0);
expect(screen.getByLabelText(/Methode/)).toHaveValue("Träufeln");
expect(screen.getByLabelText(/Dosis/)).toHaveValue("30");
expect(screen.getAllByText("aus Katalog")).toHaveLength(4);
expect(screen.queryByLabelText(/^Mittel/)).not.toBeInTheDocument();
});
it("the catalogue prefill wins over the last-treatment prefill for the same field", async () => {
vi.mocked(fetchTreatmentPrefill).mockResolvedValue({
sourceTreatmentId: "t-0",
sourceDate: "2026-08-12T00:00:00.000Z",
product: "Oxuvar",
activeIngredient: "Alte Säure",
withdrawalPeriodDays: 21,
supplier: "Imkereibedarf Muster",
purchaseProof: null,
prescribingVet: null,
});
renderPage();
await userEvent.type(screen.getByLabelText(/^Mittel/), "Oxuvar");
await waitFor(() => expect(screen.getByLabelText(/Wirkstoff/)).toHaveValue("Alte Säure"), {
timeout: 3000,
});
await chooseMedicine("Oxuvar");
await screen.findByText(PRESELECT_NOTE);
expect(screen.getByLabelText(/Wirkstoff/)).toHaveValue("Oxalsäure");
expect(screen.getByLabelText(/Wartezeit/)).toHaveValue(0);
expect(screen.getAllByText("aus Katalog")).toHaveLength(4);
expect(screen.queryByText(/aus letzter Behandlung mit Oxuvar vom 12\.08\.2026/)).not.toBeInTheDocument();
});
it("never overwrites a field the user typed", async () => {
renderPage();
await userEvent.type(screen.getByLabelText(/Wirkstoff/), "Eigener Stoff");
await chooseMedicine("Oxuvar");
expect(screen.getByLabelText(/Wirkstoff/)).toHaveValue("Eigener Stoff");
expect(screen.getByLabelText(/Wartezeit/)).toHaveValue(0);
});
it("takes the catalogue values back when „Anderes Mittel“ is chosen again", async () => {
renderPage();
await chooseMedicine("Oxuvar");
await chooseMedicine("Anderes Mittel");
expect(screen.getByLabelText(/Wirkstoff/)).toHaveValue("");
expect(screen.getByLabelText(/^Mittel/)).toHaveValue("");
expect(screen.queryByText("aus Katalog")).not.toBeInTheDocument();
expect(screen.queryByLabelText(/Dosis/)).not.toBeInTheDocument();
});
it("preselects the oldest lot and says so in a note", async () => {
renderPage();
await chooseMedicine("Oxuvar");
await waitFor(() =>
expect(screen.getByRole("combobox", { name: /Packung/ })).toHaveTextContent(
"Ch.-B. 4711 · Rest 180 ml · verw. bis 03/2027",
),
);
expect(screen.getByText(PRESELECT_NOTE)).toBeInTheDocument();
expect(fetchSelectableLots).toHaveBeenCalledWith("m-oxuvar", undefined);
});
it("shows lots as „Ch.-B. 4711 · Rest 180 ml · verw. bis 03/2027“", async () => {
renderPage();
await chooseMedicine("Oxuvar");
await screen.findByText(PRESELECT_NOTE);
await userEvent.click(screen.getByRole("combobox", { name: /Packung/ }));
const labels = (await screen.findAllByRole("option")).map((option) => option.textContent);
expect(labels).toEqual([
"Ohne Packung (nichts abbuchen)",
"Ch.-B. 4711 · Rest 180 ml · verw. bis 03/2027",
"Ch.-B. 4800 · Rest 500 ml · verw. bis 01/2028",
]);
});
it("replaces supplier, proof and vet by one line „aus Packung: …“ when a lot is chosen", async () => {
renderPage();
expect(screen.getByLabelText(/Lieferant/)).toBeInTheDocument();
await chooseMedicine("Oxuvar");
expect(await screen.findByText(SOURCE_LINE)).toBeInTheDocument();
expect(screen.queryByLabelText(/Lieferant/)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/Beleg/)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/Tierarzt/)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/Chargennummer/)).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("combobox", { name: /Packung/ }));
await userEvent.click(await screen.findByRole("option", { name: /Ohne Packung/ }));
expect(screen.getByLabelText(/Lieferant/)).toBeInTheDocument();
expect(screen.getByLabelText(/Chargennummer/)).toBeInTheDocument();
});
it("does not preselect again after „Ohne Packung“", async () => {
renderPage();
await chooseMedicine("Oxuvar");
await screen.findByText(PRESELECT_NOTE);
await userEvent.click(screen.getByRole("combobox", { name: /Packung/ }));
await userEvent.click(await screen.findByRole("option", { name: /Ohne Packung/ }));
await waitFor(() => expect(screen.queryByText(PRESELECT_NOTE)).not.toBeInTheDocument());
expect(screen.getByRole("combobox", { name: /Packung/ })).toHaveTextContent("Ohne Packung");
expect(screen.queryByText(/Bucht/)).not.toBeInTheDocument();
});
it("shows the booking line above Speichern", async () => {
renderPage();
await chooseMedicine("Oxuvar");
const line = await screen.findByText("Bucht 30 ml je Volk ab.");
const save = screen.getByRole("button", SAVE);
expect(line.compareDocumentPosition(save) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it("multiplies the booking line by the number of colonies in bulk mode", async () => {
renderPage();
await chooseBulkApiary();
await chooseMedicine("Oxuvar");
expect(await screen.findByText("Bucht 30 ml je Volk ab (× 2 = 60 ml).")).toBeInTheDocument();
});
it("warns before saving when the lot is expired or too small, but does not block", async () => {
vi.mocked(createTreatment).mockResolvedValue({ id: "t-1", colonyId: "colony-1" } as never);
renderPage();
await chooseMedicine("Oxuvar");
await screen.findByText("Bucht 30 ml je Volk ab.");
expect(screen.queryByText(/abgelaufen|reicht nicht/)).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText(/^Datum/), { target: { value: "2027-04-01" } });
await userEvent.clear(screen.getByLabelText(/Dosis/));
await userEvent.type(screen.getByLabelText(/Dosis/), "200");
expect(
await screen.findByText(/Die Packung ist abgelaufen \(verw\. bis 03\/2027\)\./),
).toBeInTheDocument();
expect(
screen.getByText(/Die Packung reicht nicht: Rest 180 ml, gebraucht 200 ml\./),
).toBeInTheDocument();
expect(screen.getByText(/Speichern ist trotzdem möglich/)).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", SAVE));
await waitFor(() => expect(createTreatment).toHaveBeenCalledTimes(1));
});
it("shows the temperature hint with a warning icon when today is outside the catalogue window", async () => {
vi.mocked(fetchColonies).mockResolvedValue(TWO_COLONIES.slice(0, 1) as never);
granted.add("apiaries:read");
vi.mocked(fetchApiaryWeather).mockResolvedValue({
daily: [{ date: todayLocalIsoDate(), tempMinC: 9, tempMaxC: 17 }],
} as never);
renderPage();
await chooseMedicine("Oxuvar");
const hint = await screen.findByText("Heute 9–17 °C. Laut Katalog 12–25 °C.");
expect(hint.closest("[data-warn='true']")).not.toBeNull();
expect(screen.getByText("Außerhalb des Katalog-Bereichs")).toBeInTheDocument();
expect(fetchApiaryWeather).toHaveBeenCalledWith("apiary-1");
});
it("shows no temperature hint and no error when the weather is missing", async () => {
vi.mocked(fetchColonies).mockResolvedValue(TWO_COLONIES.slice(0, 1) as never);
granted.add("apiaries:read");
vi.mocked(fetchApiaryWeather).mockRejectedValue(new ApiError(404, "Kein Wetter"));
renderPage();
await chooseMedicine("Oxuvar");
await screen.findByText("Bucht 30 ml je Volk ab.");
await waitFor(() => expect(fetchApiaryWeather).toHaveBeenCalled());
expect(screen.queryByText(/Laut Katalog/)).not.toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it("sends medicineId, medicineLotId and doseQuantity and omits the lot-sourced text fields", async () => {
vi.mocked(createTreatment).mockResolvedValue({ id: "t-1", colonyId: "colony-1" } as never);
renderPage();
await chooseMedicine("Oxuvar");
await screen.findByText(SOURCE_LINE);
await userEvent.clear(screen.getByLabelText(/Dosis/));
await userEvent.type(screen.getByLabelText(/Dosis/), "2,5");
await userEvent.click(screen.getByRole("button", SAVE));
await waitFor(() => expect(createTreatment).toHaveBeenCalledTimes(1));
const [input] = vi.mocked(createTreatment).mock.calls[0];
expect(input).toMatchObject({
colonyId: "colony-1",
medicineId: "m-oxuvar",
medicineLotId: "l-old",
doseQuantity: 2.5,
activeIngredient: "Oxalsäure",
withdrawalPeriodDays: 0,
});
for (const omitted of ["product", "supplier", "purchaseProof", "batchNumber", "prescribingVet"]) {
expect(input).not.toHaveProperty(omitted);
}
});
it("keeps the typed text fields when a catalogue medicine is chosen without a pack", async () => {
vi.mocked(fetchSelectableLots).mockResolvedValue([]);
vi.mocked(createTreatment).mockResolvedValue({ id: "t-1", colonyId: "colony-1" } as never);
renderPage();
await chooseMedicine("Oxuvar");
expect(await screen.findByText(/Keine Packung mit Rest/)).toBeInTheDocument();
await userEvent.type(screen.getByLabelText(/Lieferant/), "Imkereibedarf Muster");
await userEvent.click(screen.getByRole("button", SAVE));
await waitFor(() => expect(createTreatment).toHaveBeenCalledTimes(1));
const [input] = vi.mocked(createTreatment).mock.calls[0];
expect(input).toMatchObject({ medicineId: "m-oxuvar", supplier: "Imkereibedarf Muster" });
expect(input).not.toHaveProperty("medicineLotId");
});
it("refuses a dose below 0,001 or an ambiguous one and asks for it again", async () => {
renderPage();
await chooseMedicine("Oxuvar");
await screen.findByText("Bucht 30 ml je Volk ab.");
await userEvent.clear(screen.getByLabelText(/Dosis/));
await userEvent.type(screen.getByLabelText(/Dosis/), "0");
await userEvent.click(screen.getByRole("button", SAVE));
expect(await screen.findByText("Die Dosis muss mindestens 0,001 sein.")).toBeInTheDocument();
expect(screen.getByLabelText(/Dosis/)).toHaveFocus();
await userEvent.clear(screen.getByLabelText(/Dosis/));
await userEvent.type(screen.getByLabelText(/Dosis/), "1.000");
await userEvent.click(screen.getByRole("button", SAVE));
expect(await screen.findByText(/ohne Tausenderpunkt/)).toBeInTheDocument();
expect(createTreatment).not.toHaveBeenCalled();
});
it("bulk mode sends one POST /treatments/bulk with the shared medicine fields", async () => {
vi.mocked(createTreatmentsBulk).mockResolvedValue({ bulkId: "b-1", records: [] } as never);
renderPage();
await chooseBulkApiary();
await chooseMedicine("Oxuvar");
await screen.findByText(SOURCE_LINE);
await userEvent.click(screen.getByRole("button", SAVE));
await waitFor(() => expect(createTreatmentsBulk).toHaveBeenCalledTimes(1));
const [input] = vi.mocked(createTreatmentsBulk).mock.calls[0];
expect(input).toMatchObject({
colonyIds: ["colony-1", "colony-2"],
medicineId: "m-oxuvar",
medicineLotId: "l-old",
doseQuantity: 30,
});
expect(input).not.toHaveProperty("supplier");
expect(input).not.toHaveProperty("product");
expect(createTreatment).not.toHaveBeenCalled();
});
it("a changed body after an error gets a new Idempotency-Key", async () => {
vi.mocked(createTreatment).mockRejectedValue(
new ApiError(400, "Diese Angabe kommt aus der Packung"),
);
renderPage();
await chooseMedicine("Oxuvar");
await screen.findByText("Bucht 30 ml je Volk ab.");
await userEvent.click(screen.getByRole("button", SAVE));
await waitFor(() => expect(createTreatment).toHaveBeenCalledTimes(1));
await userEvent.clear(screen.getByLabelText(/Dosis/));
await userEvent.type(screen.getByLabelText(/Dosis/), "20");
await userEvent.click(screen.getByRole("button", SAVE));
await waitFor(() => expect(createTreatment).toHaveBeenCalledTimes(2));
const keys = vi.mocked(createTreatment).mock.calls.map(([, options]) => options?.idempotencyKey);
expect(keys[1]).not.toBe(keys[0]);
});
});
describe("restoring a draft", () => {
function storeDraft(medicineId: string) {
window.localStorage.setItem(
"apicore:draft:user-1:treatment-new",
JSON.stringify({
savedAt: Date.now(),
values: {
colonyId: "colony-1",
date: "2026-08-01",
product: "Oxuvar",
medicineId,
medicineLotId: "l-old",
doseQuantity: "30",
},
}),
);
}
it("drops a medicine that is switched off since and says so", async () => {
granted.add("veterinary-medicines:read");
vi.mocked(fetchMedicines).mockResolvedValue([
medicine({ id: "m-oxuvar", name: "Oxuvar", active: false }),
]);
storeDraft("m-oxuvar");
renderPage();
await waitFor(() => expect(fetchMedicines).toHaveBeenCalled());
await new Promise((resolve) => setTimeout(resolve, 50));
await userEvent.click(await screen.findByRole("button", { name: "Weitermachen" }));
expect(
await screen.findByText(/Das Mittel aus dem Entwurf ist im Katalog nicht mehr aktiv/),
).toBeInTheDocument();
expect(screen.getByLabelText(/^Mittel/)).toHaveValue("Oxuvar");
expect(screen.queryByRole("combobox", { name: /Packung/ })).not.toBeInTheDocument();
});
it("keeps a medicine that is still active", async () => {
granted.add("veterinary-medicines:read");
vi.mocked(fetchMedicines).mockResolvedValue([
medicine({ id: "m-oxuvar", name: "Oxuvar", unit: "ML" }),
]);
storeDraft("m-oxuvar");
renderPage();
await waitFor(() => expect(fetchMedicines).toHaveBeenCalled());
await new Promise((resolve) => setTimeout(resolve, 50));
await userEvent.click(await screen.findByRole("button", { name: "Weitermachen" }));
expect(await screen.findByRole("combobox", { name: /Packung/ })).toBeInTheDocument();
expect(screen.queryByText(/nicht mehr aktiv/)).not.toBeInTheDocument();
});
});
}); });
@@ -2,9 +2,12 @@
import { FormEvent, useState } from "react"; import { FormEvent, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { Syringe } from "lucide-react"; import { Syringe } from "lucide-react";
import { saveErrorText } from "@/lib/honey-batches/errors";
import { useAuth } from "@/lib/auth/auth-context";
import { fetchColonies } from "@/lib/colonies/api";
import { createTreatment } from "@/lib/treatments/api"; import { createTreatment } from "@/lib/treatments/api";
import { invalidateVarroaBands } from "@/lib/varroa/use-varroa-bands"; import { invalidateVarroaBands } from "@/lib/varroa/use-varroa-bands";
import { createTreatmentsBulk } from "@/lib/bulk/api"; import { createTreatmentsBulk } from "@/lib/bulk/api";
@@ -15,13 +18,24 @@ import { queueTreatment } from "@/lib/offline/db";
import { useCaptureMutation } from "@/lib/offline/use-capture-mutation"; import { useCaptureMutation } from "@/lib/offline/use-capture-mutation";
import { captureBackTarget } from "@/lib/forms/back-target"; import { captureBackTarget } from "@/lib/forms/back-target";
import { useFormDraft } from "@/lib/forms/use-form-draft"; import { useFormDraft } from "@/lib/forms/use-form-draft";
import { doseError } from "@/lib/treatments/dose";
import { lotSourceLine } from "@/lib/treatments/lot-text";
import { useCataloguePrefill } from "@/lib/treatments/use-catalogue-prefill";
import { useMedicineChoice } from "@/lib/treatments/use-medicine-choice";
import { usePrefilledFields } from "@/lib/treatments/use-prefilled-fields"; import { usePrefilledFields } from "@/lib/treatments/use-prefilled-fields";
import { PrefillNote, describedByWith } from "@/components/treatments/prefill-note"; import { PrefillNote, describedByWith } from "@/components/treatments/prefill-note";
import {
BookingNotes,
CatalogueNote,
MedicineLotFields,
describedByCatalogue,
} from "@/components/treatments/medicine-lot-fields";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { FormBackLink } from "@/components/form/form-back-link"; import { FormBackLink } from "@/components/form/form-back-link";
import { PageHeader } from "@/components/page-header"; import { PageHeader } from "@/components/page-header";
import type { MedicineView } from "@/lib/medicines/types";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Field } from "@/components/form/field"; import { Field } from "@/components/form/field";
import { BulkSubmitNote } from "@/components/form/bulk-submit-note"; import { BulkSubmitNote } from "@/components/form/bulk-submit-note";
@@ -36,14 +50,21 @@ import {
toTreatmentInput, toTreatmentInput,
} from "./treatment-form-values"; } from "./treatment-form-values";
type RequiredKey = "colonyId" | "bulkColonies" | "date" | "product"; type RequiredKey = "colonyId" | "bulkColonies" | "date" | "product" | "doseQuantity";
type FieldErrors = Partial<Record<RequiredKey, string>>; type FieldErrors = Partial<Record<RequiredKey, string>>;
const SAVE_FAILED = "Behandlung konnte nicht gespeichert werden.";
const GONE_MEDICINE_NOTE =
"Das Mittel aus dem Entwurf ist im Katalog nicht mehr aktiv. Bitte wähle es neu.";
const SHARED_NOTE = "Gilt für alle gewählten Völker."; const SHARED_NOTE = "Gilt für alle gewählten Völker.";
const BATCH_HINT = "Nummer der Packung des Mittels, meist „Ch.-B.“ oder „Lot“."; const BATCH_HINT = "Nummer der Packung des Mittels, meist „Ch.-B.“ oder „Lot“.";
function requiredKeys(values: TreatmentFormValues): RequiredKey[] { function requiredKeys(values: TreatmentFormValues): RequiredKey[] {
return [values.target === "bulk" ? "bulkColonies" : "colonyId", "date", "product"]; return [
values.target === "bulk" ? "bulkColonies" : "colonyId",
"date",
values.medicineId ? "doseQuantity" : "product",
];
} }
function validate(values: TreatmentFormValues): FieldErrors { function validate(values: TreatmentFormValues): FieldErrors {
@@ -54,7 +75,8 @@ function validate(values: TreatmentFormValues): FieldErrors {
errors.colonyId = "Bitte ein Volk auswählen."; errors.colonyId = "Bitte ein Volk auswählen.";
} }
if (!values.date) errors.date = "Bitte ein Datum angeben."; if (!values.date) errors.date = "Bitte ein Datum angeben.";
if (!values.product.trim()) errors.product = "Bitte das Mittel angeben."; if (!values.medicineId && !values.product.trim()) errors.product = "Bitte das Mittel angeben.";
if (values.medicineId) errors.doseQuantity = doseError(values.doseQuantity) ?? undefined;
return errors; return errors;
} }
@@ -72,11 +94,20 @@ export default function NewTreatmentPage() {
initialTreatmentValues(searchParams.get("colonyId") ?? ""), initialTreatmentValues(searchParams.get("colonyId") ?? ""),
); );
const [dirty, setDirty] = useState(false); const [dirty, setDirty] = useState(false);
const [medicineNote, setMedicineNote] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({}); const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const draft = useFormDraft("treatment-new", values, dirty); const draft = useFormDraft("treatment-new", values, dirty);
const { prefilled, markEdited, undo: undoPrefill, reset: resetPrefill } = const { prefilled, markEdited, undo: undoPrefill, reset: resetPrefill } =
usePrefilledFields(values, setValues); usePrefilledFields(values, setValues);
const catalogue = useCataloguePrefill(setValues, markEdited);
const { hasPermission } = useAuth();
const choice = useMedicineChoice({
canRead: hasPermission("veterinary-medicines:read"),
medicineId: values.medicineId,
medicineLotId: values.medicineLotId,
onPreselect: (lotId) => setValues((current) => ({ ...current, medicineLotId: lotId })),
});
function set<K extends keyof TreatmentFormValues>( function set<K extends keyof TreatmentFormValues>(
key: K, key: K,
@@ -85,15 +116,49 @@ export default function NewTreatmentPage() {
setValues((current) => ({ ...current, [key]: value })); setValues((current) => ({ ...current, [key]: value }));
setDirty(true); setDirty(true);
markEdited(key); markEdited(key);
catalogue.edited(key);
// Whatever was wrong with the field is wrong no longer; the colonies of a // Whatever was wrong with the field is wrong no longer; the colonies of a
// bulk capture are one field in three values. // bulk capture are one field in three values.
const field = key === "bulkColonyIds" || key === "bulkApiaryId" ? "bulkColonies" : key; const field = key === "bulkColonyIds" || key === "bulkApiaryId" ? "bulkColonies" : key;
setFieldErrors((current) => ({ ...current, [field]: undefined })); setFieldErrors((current) => ({ ...current, [field]: undefined }));
} }
/** A catalogue medicine was chosen (`null`: „Anderes Mittel“); its values are suggestions. */
function chooseMedicine(medicine: MedicineView | null) {
choice.undecideLot();
setMedicineNote(null);
setDirty(true);
setValues((current) => ({
...current,
medicineId: medicine?.id ?? "",
medicineLotId: "",
// The trade name follows the catalogue; „Anderes Mittel“ starts empty.
product: medicine ? medicine.name : current.medicineId ? "" : current.product,
}));
setFieldErrors((current) => ({ ...current, product: undefined, doseQuantity: undefined }));
catalogue.apply(medicine);
}
function chooseLot(lotId: string) {
choice.decideLot();
set("medicineLotId", lotId);
}
const isBulk = values.target === "bulk"; const isBulk = values.target === "bulk";
const hasLot = values.medicineLotId !== "";
const coloniesQuery = useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() });
const weatherApiaryId = isBulk
? values.bulkApiaryId
: (coloniesQuery.data?.find((colony) => colony.id === values.colonyId)?.apiaryId ?? null);
const apiaryColonies = useApiaryColonies(isBulk ? values.bulkApiaryId : null); const apiaryColonies = useApiaryColonies(isBulk ? values.bulkApiaryId : null);
/** A booked dose changes the rest of the pack and of the medicine. */
function invalidateMedicineStock(medicineLotId: string | null | undefined) {
if (!medicineLotId) return;
void queryClient.invalidateQueries({ queryKey: ["medicine-lots"] });
void queryClient.invalidateQueries({ queryKey: ["medicines"] });
}
const bulkCapture = useBulkCapture({ const bulkCapture = useBulkCapture({
kind: "treatments", kind: "treatments",
create: createTreatmentsBulk, create: createTreatmentsBulk,
@@ -101,6 +166,7 @@ export default function NewTreatmentPage() {
onSaved: (outcome, input) => { onSaved: (outcome, input) => {
draft.clear(); draft.clear();
const count = input.colonyIds.length; const count = input.colonyIds.length;
invalidateMedicineStock(input.medicineLotId);
if (outcome.via === "queued") { if (outcome.via === "queued") {
toast.info(bulkQueuedText("Behandlung", count)); toast.info(bulkQueuedText("Behandlung", count));
} else { } else {
@@ -118,8 +184,11 @@ export default function NewTreatmentPage() {
create: createTreatment, create: createTreatment,
queue: queueTreatment, queue: queueTreatment,
failureMessage: "Behandlung konnte nicht gespeichert werden.", failureMessage: "Behandlung konnte nicht gespeichert werden.",
// A changed body after an error (the pack, the dose) is another booking.
keyPerSubmission: true,
onSaved: (outcome, input) => { onSaved: (outcome, input) => {
draft.clear(); draft.clear();
invalidateMedicineStock(input.medicineLotId);
if (outcome.via === "queued") { if (outcome.via === "queued") {
toast.info( toast.info(
"Kein Netzwerk – Behandlung wurde offline gespeichert und wird synchronisiert.", "Kein Netzwerk – Behandlung wurde offline gespeichert und wird synchronisiert.",
@@ -169,8 +238,20 @@ export default function NewTreatmentPage() {
const restored = draft.restore(); const restored = draft.restore();
if (restored) { if (restored) {
// A draft from before the bulk choice existed lacks its values. // A draft from before the bulk choice existed lacks its values.
setValues({ ...initialTreatmentValues(""), ...restored }); let full = { ...initialTreatmentValues(""), ...restored };
// A medicine that is gone or switched off since is not booked silently.
const known = choice.allMedicines?.find((medicine) => medicine.id === full.medicineId);
const isGone =
full.medicineId !== "" &&
(!choice.canRead || (choice.allMedicines !== undefined && !known?.active));
if (isGone) {
full = { ...full, medicineId: "", medicineLotId: "" };
}
setMedicineNote(isGone ? GONE_MEDICINE_NOTE : null);
setValues(full);
resetPrefill(); resetPrefill();
catalogue.resetFrom(full);
if (full.medicineId) choice.decideLot();
setDirty(true); setDirty(true);
} }
} }
@@ -235,25 +316,24 @@ export default function NewTreatmentPage() {
/> />
)} )}
</Field> </Field>
<Field <div role="status" aria-live="polite" className="empty:hidden">
label="Mittel" {medicineNote ? <p className="rounded-lg bg-warning/20 px-3 py-2 text-sm">{medicineNote}</p> : null}
required </div>
error={fieldErrors.product} <MedicineLotFields
hint="Handelsname, z. B. „Oxuvar“" idPrefix="treatment"
> choice={choice}
{({ id, describedBy, invalid }) => ( product={values.product}
<Input medicineId={values.medicineId}
id={id} medicineLotId={values.medicineLotId}
data-field="product" doseQuantity={values.doseQuantity}
required productError={fieldErrors.product}
className="h-11" doseError={fieldErrors.doseQuantity}
aria-describedby={describedBy} filled={catalogue.filled}
aria-invalid={invalid} onProductChange={(product) => set("product", product)}
value={values.product} onMedicineChange={chooseMedicine}
onChange={(event) => set("product", event.target.value)} onLotChange={chooseLot}
/> onDoseChange={(dose) => set("doseQuantity", dose)}
)} />
</Field>
</FormSection> </FormSection>
<FormSection step={2} title="Mittel & Anwendung"> <FormSection step={2} title="Mittel & Anwendung">
@@ -265,7 +345,11 @@ export default function NewTreatmentPage() {
<> <>
<Input <Input
id={id} id={id}
aria-describedby={describedByWith(id, describedBy, prefilled.activeIngredient)} aria-describedby={describedByWith(
id,
describedByCatalogue(id, describedBy, catalogue.filled.activeIngredient),
prefilled.activeIngredient,
)}
className="h-11" className="h-11"
value={values.activeIngredient} value={values.activeIngredient}
onChange={(event) => onChange={(event) =>
@@ -273,6 +357,7 @@ export default function NewTreatmentPage() {
} }
/> />
<PrefillNote id={id} source={prefilled.activeIngredient} /> <PrefillNote id={id} source={prefilled.activeIngredient} />
<CatalogueNote id={id} shown={catalogue.filled.activeIngredient} />
</> </>
)} )}
</Field> </Field>
@@ -289,29 +374,34 @@ export default function NewTreatmentPage() {
</Field> </Field>
<Field label="Methode" hint="z. B. Träufeln, Verdunsten"> <Field label="Methode" hint="z. B. Träufeln, Verdunsten">
{({ id, describedBy }) => ( {({ id, describedBy }) => (
<Input <>
id={id} <Input
aria-describedby={describedBy} id={id}
className="h-11" aria-describedby={describedByCatalogue(id, describedBy, catalogue.filled.method)}
value={values.method} className="h-11"
onChange={(event) => set("method", event.target.value)} value={values.method}
/> onChange={(event) => set("method", event.target.value)}
)} />
</Field> <CatalogueNote id={id} shown={catalogue.filled.method} />
<Field </>
label="Chargennummer"
hint={isBulk ? `${BATCH_HINT} ${SHARED_NOTE}` : BATCH_HINT}
>
{({ id, describedBy }) => (
<Input
id={id}
aria-describedby={describedBy}
className="h-11"
value={values.batchNumber}
onChange={(event) => set("batchNumber", event.target.value)}
/>
)} )}
</Field> </Field>
{hasLot ? null : (
<Field
label="Chargennummer"
hint={isBulk ? `${BATCH_HINT} ${SHARED_NOTE}` : BATCH_HINT}
>
{({ id, describedBy }) => (
<Input
id={id}
aria-describedby={describedBy}
className="h-11"
value={values.batchNumber}
onChange={(event) => set("batchNumber", event.target.value)}
/>
)}
</Field>
)}
<Field <Field
label="Wartezeit (Tage)" label="Wartezeit (Tage)"
hint="Laut Packungsbeilage des Mittels – solange darf aus diesem Volk kein Honig geerntet werden." hint="Laut Packungsbeilage des Mittels – solange darf aus diesem Volk kein Honig geerntet werden."
@@ -320,7 +410,11 @@ export default function NewTreatmentPage() {
<> <>
<Input <Input
id={id} id={id}
aria-describedby={describedByWith(id, describedBy, prefilled.withdrawalPeriodDays)} aria-describedby={describedByWith(
id,
describedByCatalogue(id, describedBy, catalogue.filled.withdrawalPeriodDays),
prefilled.withdrawalPeriodDays,
)}
type="number" type="number"
inputMode="numeric" inputMode="numeric"
min={0} min={0}
@@ -331,6 +425,7 @@ export default function NewTreatmentPage() {
} }
/> />
<PrefillNote id={id} source={prefilled.withdrawalPeriodDays} /> <PrefillNote id={id} source={prefilled.withdrawalPeriodDays} />
<CatalogueNote id={id} shown={catalogue.filled.withdrawalPeriodDays} />
</> </>
)} )}
</Field> </Field>
@@ -341,50 +436,58 @@ export default function NewTreatmentPage() {
title="Für das Bestandsbuch" 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." description="Pflicht bei jedem Tierarzneimittel, auch bei freiverkäuflichen Säuren (Art. 108 VO (EU) 2019/6). Belege 5 Jahre aufbewahren."
> >
<Field label="Lieferant (Name, Anschrift)"> {hasLot ? (
{({ id }) => ( <p className="rounded-lg bg-muted/60 px-3 py-2 text-sm">
<> {choice.lot ? lotSourceLine(choice.lot) : "aus Packung"}
<Input </p>
id={id} ) : (
aria-describedby={describedByWith(id, undefined, prefilled.supplier)} <>
className="h-11" <Field label="Lieferant (Name, Anschrift)">
placeholder="z. B. Imkereibedarf Muster, Hauptstr. 1, 86450 Altenmünster" {({ id }) => (
value={values.supplier} <>
onChange={(event) => set("supplier", event.target.value)} <Input
/> id={id}
<PrefillNote id={id} source={prefilled.supplier} /> aria-describedby={describedByWith(id, undefined, prefilled.supplier)}
</> className="h-11"
)} placeholder="z. B. Imkereibedarf Muster, Hauptstr. 1, 86450 Altenmünster"
</Field> value={values.supplier}
<Field label="Beleg für den Erwerb"> onChange={(event) => set("supplier", event.target.value)}
{({ id }) => ( />
<> <PrefillNote id={id} source={prefilled.supplier} />
<Input </>
id={id} )}
aria-describedby={describedByWith(id, undefined, prefilled.purchaseProof)} </Field>
className="h-11" <Field label="Beleg für den Erwerb">
placeholder="z. B. Rechnung Nr. 4711 vom 01.08.2026" {({ id }) => (
value={values.purchaseProof} <>
onChange={(event) => set("purchaseProof", event.target.value)} <Input
/> id={id}
<PrefillNote id={id} source={prefilled.purchaseProof} /> aria-describedby={describedByWith(id, undefined, prefilled.purchaseProof)}
</> className="h-11"
)} placeholder="z. B. Rechnung Nr. 4711 vom 01.08.2026"
</Field> value={values.purchaseProof}
<Field label="Verschreibender Tierarzt (falls zutreffend)"> onChange={(event) => set("purchaseProof", event.target.value)}
{({ id }) => ( />
<> <PrefillNote id={id} source={prefilled.purchaseProof} />
<Input </>
id={id} )}
aria-describedby={describedByWith(id, undefined, prefilled.prescribingVet)} </Field>
className="h-11" <Field label="Verschreibender Tierarzt (falls zutreffend)">
value={values.prescribingVet} {({ id }) => (
onChange={(event) => set("prescribingVet", event.target.value)} <>
/> <Input
<PrefillNote id={id} source={prefilled.prescribingVet} /> id={id}
</> aria-describedby={describedByWith(id, undefined, prefilled.prescribingVet)}
)} className="h-11"
</Field> value={values.prescribingVet}
onChange={(event) => set("prescribingVet", event.target.value)}
/>
<PrefillNote id={id} source={prefilled.prescribingVet} />
</>
)}
</Field>
</>
)}
{Object.keys(prefilled).length > 0 ? ( {Object.keys(prefilled).length > 0 ? (
<Button <Button
type="button" type="button"
@@ -453,23 +556,33 @@ export default function NewTreatmentPage() {
<StickySubmit <StickySubmit
note={ note={
isBulk ? ( <>
<BulkSubmitNote <BookingNotes
submitError={bulkCapture.submitError} choice={choice}
isRetryLater={bulkCapture.isRetryLater} doseQuantity={values.doseQuantity}
invalidColonyIds={bulkCapture.invalidColonyIds} colonyCount={isBulk ? values.bulkColonyIds.length : 1}
colonies={apiaryColonies.data?.items} treatmentDay={values.date}
isPending={bulkCapture.isPending} apiaryId={weatherApiaryId}
onResendWithout={resendWithoutRefused} canReadApiaries={hasPermission("apiaries:read")}
/> />
) : capture.submitError ? ( {isBulk ? (
<p <BulkSubmitNote
role="alert" submitError={bulkCapture.submitError}
className="text-sm font-medium text-destructive" isRetryLater={bulkCapture.isRetryLater}
> invalidColonyIds={bulkCapture.invalidColonyIds}
{capture.submitError} colonies={apiaryColonies.data?.items}
</p> isPending={bulkCapture.isPending}
) : null onResendWithout={resendWithoutRefused}
/>
) : capture.submitError ? (
<p
role="alert"
className="text-sm font-medium text-destructive"
>
{saveErrorText(capture.error, SAVE_FAILED)}
</p>
) : null}
</>
} }
> >
<Button <Button
@@ -3,6 +3,7 @@ import type { BulkCreateTreatmentInput } from "@/lib/bulk/api";
import type { CaptureTarget } from "@/components/form/capture-target-toggle"; import type { CaptureTarget } from "@/components/form/capture-target-toggle";
import { toApiDate, todayLocalIsoDate } from "@/lib/dates"; import { toApiDate, todayLocalIsoDate } from "@/lib/dates";
import { emptyToNull } from "@/lib/forms/empty-to-null"; import { emptyToNull } from "@/lib/forms/empty-to-null";
import { parsedDose } from "@/lib/treatments/dose";
/** Everything the form holds, as plain values that survive a draft save. */ /** Everything the form holds, as plain values that survive a draft save. */
export interface TreatmentFormValues { export interface TreatmentFormValues {
@@ -15,6 +16,12 @@ export interface TreatmentFormValues {
startDate: string; startDate: string;
endDate: string; endDate: string;
product: string; product: string;
/** Catalogue medicine ("" = Anderes Mittel, typed by hand). */
medicineId: string;
/** Chosen pack ("" = none; nothing is booked then). */
medicineLotId: string;
/** Dose per colony in the unit of the medicine, as typed ("2,5"). */
doseQuantity: string;
activeIngredient: string; activeIngredient: string;
amount: string; amount: string;
method: string; method: string;
@@ -37,6 +44,9 @@ export function initialTreatmentValues(colonyId: string): TreatmentFormValues {
startDate: "", startDate: "",
endDate: "", endDate: "",
product: "", product: "",
medicineId: "",
medicineLotId: "",
doseQuantity: "",
activeIngredient: "", activeIngredient: "",
amount: "", amount: "",
method: "", method: "",
@@ -50,23 +60,46 @@ export function initialTreatmentValues(colonyId: string): TreatmentFormValues {
}; };
} }
/**
* The fields the pack supplies. With a pack they are left out of the payload
* (the server answers 400 for text there); without one they are the typed text.
*/
function ownSourceFields(values: TreatmentFormValues) {
if (values.medicineLotId) return {};
return {
batchNumber: emptyToNull(values.batchNumber),
supplier: emptyToNull(values.supplier),
purchaseProof: emptyToNull(values.purchaseProof),
prescribingVet: emptyToNull(values.prescribingVet),
};
}
/** Catalogue medicine, pack and dose; empty choices are left out of a new treatment. */
function medicineFields(values: TreatmentFormValues) {
const dose = parsedDose(values.doseQuantity);
return {
...(values.medicineId ? { medicineId: values.medicineId } : {}),
...(values.medicineLotId ? { medicineLotId: values.medicineLotId } : {}),
...(values.medicineId && dose !== null ? { doseQuantity: dose } : {}),
};
}
/** Everything of the treatment that is the same for one colony or for many. */ /** Everything of the treatment that is the same for one colony or for many. */
function sharedTreatmentFields(values: TreatmentFormValues) { function sharedTreatmentFields(values: TreatmentFormValues) {
return { return {
date: toApiDate(values.date), date: toApiDate(values.date),
startDate: emptyToNull(values.startDate, toApiDate), startDate: emptyToNull(values.startDate, toApiDate),
endDate: emptyToNull(values.endDate, toApiDate), endDate: emptyToNull(values.endDate, toApiDate),
product: values.product.trim(), // A catalogue medicine gives the trade name itself.
...(values.medicineId ? {} : { product: values.product.trim() }),
...medicineFields(values),
activeIngredient: emptyToNull(values.activeIngredient), activeIngredient: emptyToNull(values.activeIngredient),
amount: emptyToNull(values.amount), amount: emptyToNull(values.amount),
method: emptyToNull(values.method), method: emptyToNull(values.method),
reason: emptyToNull(values.reason), reason: emptyToNull(values.reason),
batchNumber: emptyToNull(values.batchNumber),
notes: emptyToNull(values.notes), notes: emptyToNull(values.notes),
withdrawalPeriodDays: emptyToNull(values.withdrawalPeriodDays, Number), withdrawalPeriodDays: emptyToNull(values.withdrawalPeriodDays, Number),
supplier: emptyToNull(values.supplier), ...ownSourceFields(values),
purchaseProof: emptyToNull(values.purchaseProof),
prescribingVet: emptyToNull(values.prescribingVet),
}; };
} }
@@ -76,7 +109,7 @@ export function toTreatmentInput(
return { colonyId: values.colonyId, ...sharedTreatmentFields(values) }; return { colonyId: values.colonyId, ...sharedTreatmentFields(values) };
} }
/** One treatment for the chosen colonies; the batch number is what the user typed. */ /** One treatment for the chosen colonies; with a pack each colony books its own dose. */
export function toBulkTreatmentInput(values: TreatmentFormValues): BulkCreateTreatmentInput { export function toBulkTreatmentInput(values: TreatmentFormValues): BulkCreateTreatmentInput {
return { ...sharedTreatmentFields(values), colonyIds: values.bulkColonyIds }; return { ...sharedTreatmentFields(values), colonyIds: values.bulkColonyIds };
} }
@@ -0,0 +1,18 @@
import Link from "next/link";
/** Shown instead of a page whose data needs `compliance:read`: a retry would not help. */
export function NoComplianceAccess() {
return (
<main className="flex flex-col gap-3 p-4 sm:p-6">
<p role="status" className="text-sm">
Dazu fehlt dir das Recht.
</p>
<Link
href="/dashboard"
className="inline-flex min-h-11 items-center self-start text-sm underline underline-offset-2"
>
Zurück zum Start
</Link>
</main>
);
}
@@ -0,0 +1,27 @@
import { CheckCircle2, Circle, XCircle, type LucideIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { NOTICE_STATUS_LABEL } from "@/lib/compliance/notice-labels";
import type { AuthorityNoticeStatus } from "@/lib/compliance/notice-types";
const STATUS_ICON: Record<AuthorityNoticeStatus, LucideIcon> = {
DRAFT: Circle,
SENT: CheckCircle2,
CANCELLED: XCircle,
};
const STATUS_VARIANT: Record<AuthorityNoticeStatus, "outline" | "default" | "secondary"> = {
DRAFT: "outline",
SENT: "default",
CANCELLED: "secondary",
};
/** The status of a notice as symbol AND word, never colour alone. */
export function NoticeStatus({ status }: { status: AuthorityNoticeStatus }) {
const Icon = STATUS_ICON[status];
return (
<Badge variant={STATUS_VARIANT[status]} className="h-6 gap-1.5 px-2.5 text-sm">
<Icon aria-hidden />
{NOTICE_STATUS_LABEL[status]}
</Badge>
);
}
@@ -0,0 +1,21 @@
"use client";
import type { RestrictionGuard } from "@/lib/compliance/use-restriction-guard";
import { RestrictionNotice } from "./restriction-notice";
import { RestrictionOverride } from "./restriction-override";
/** The red notice and, only while something blocks, the folded override. */
export function RestrictionGuardPanel({
guard,
blockedText,
}: {
guard: RestrictionGuard;
blockedText: string;
}) {
return (
<>
<RestrictionNotice live id={guard.noticeId} restrictions={guard.blocking} blockedText={blockedText} />
{guard.isBlocked ? <RestrictionOverride key={guard.resetKey} onReason={guard.setReason} /> : null}
</>
);
}
@@ -0,0 +1,113 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fetchActiveRestrictions } from "@/lib/compliance/restrictions-api";
import {
HarvestRestrictionWarning,
MoveHint,
RestrictionBand,
RestrictionNotice,
} from "./restriction-notice";
vi.mock("@/lib/compliance/restrictions-api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/compliance/restrictions-api")>()),
fetchActiveRestrictions: vi.fn(),
}));
const restriction = {
id: "r-1",
apiaryId: "a-1",
apiaryName: "Obstwiese",
disease: "Amerikanische Faulbrut",
startsOn: "2026-10-01",
};
describe("RestrictionNotice", () => {
it("names the apiary, the disease, the start day and the blocked action in red with a symbol", () => {
render(<RestrictionNotice id="notice" restrictions={[restriction]} />);
const text = screen.getByText(
"Stand Obstwiese liegt im Sperrbezirk (Amerikanische Faulbrut, seit 01.10.2026). Umsetzen ist gesperrt.",
);
const box = text.closest("[data-restriction-notice]");
expect(box).toHaveClass("text-destructive");
expect(box?.querySelector("svg")).not.toBeNull();
expect(box).toHaveAttribute("id", "notice");
});
it("says one sentence per restricted apiary and takes another blocked action", () => {
render(
<RestrictionNotice
blockedText="Anlegen ist gesperrt."
restrictions={[restriction, { ...restriction, id: "r-2", apiaryName: "Garten", disease: "Kleiner Beutenkäfer" }]}
/>,
);
expect(screen.getAllByText(/liegt im Sperrbezirk/)).toHaveLength(2);
expect(screen.getAllByText(/Anlegen ist gesperrt\.$/)).toHaveLength(2);
});
it("renders nothing without restrictions", () => {
const { container } = render(<RestrictionNotice restrictions={[]} />);
expect(container).toBeEmptyDOMElement();
});
});
describe("MoveHint", () => {
it("shows the fixed BienSeuchV § 5 hint", () => {
render(<MoveHint />);
expect(
screen.getByText(
"Beim Umsetzen an einen anderen Standort kann eine Gesundheitsbescheinigung nötig sein (BienSeuchV § 5). Im Zweifel beim Veterinäramt nachfragen.",
),
).toBeInTheDocument();
});
});
describe("RestrictionBand", () => {
it("is a red band with a link to the list when the reader may open it", () => {
render(<RestrictionBand restrictions={[restriction]} canOpenList />);
expect(screen.getByText(/liegt im Sperrbezirk/).closest("[data-restriction-band]")).toHaveClass(
"text-destructive",
);
expect(screen.getByRole("link", { name: "Sperrbezirke ansehen" })).toHaveAttribute(
"href",
"/compliance/restrictions",
);
});
it("has no link without the right to read the list", () => {
render(<RestrictionBand restrictions={[restriction]} canOpenList={false} />);
expect(screen.queryByRole("link")).not.toBeInTheDocument();
});
});
describe("HarvestRestrictionWarning", () => {
beforeEach(() => {
vi.mocked(fetchActiveRestrictions).mockReset();
});
function renderWarning(apiaryId: string) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<HarvestRestrictionWarning apiaryId={apiaryId} />
</QueryClientProvider>,
);
}
it("warns only for a restricted apiary and keeps a polite live region either way", async () => {
vi.mocked(fetchActiveRestrictions).mockResolvedValue([restriction]);
const { container } = renderWarning("a-1");
expect(
await screen.findByText("Im Sperrbezirk gelten Auflagen auch für Honig und Waben. Verfügung prüfen."),
).toBeInTheDocument();
expect(container.querySelector("[aria-live=polite]")).not.toBeNull();
});
it("says nothing for a free apiary", async () => {
vi.mocked(fetchActiveRestrictions).mockResolvedValue([restriction]);
renderWarning("a-2");
await vi.waitFor(() => expect(fetchActiveRestrictions).toHaveBeenCalled());
expect(screen.queryByText(/Im Sperrbezirk gelten Auflagen/)).not.toBeInTheDocument();
});
});
@@ -0,0 +1,127 @@
"use client";
import Link from "next/link";
import { AlertTriangle, OctagonAlert } from "lucide-react";
import { Button } from "@/components/ui/button";
import { restrictionsAmong, type ActiveRestriction } from "@/lib/compliance/restrictions-api";
import { useActiveRestrictions } from "@/lib/compliance/use-active-restrictions";
import { formatDay } from "@/lib/medicines/format";
/** The blocked action, worded for the place the notice appears in. */
export const BLOCKED_TEXT = {
move: "Umsetzen ist gesperrt.",
create: "Anlegen ist gesperrt.",
merge: "Vereinigen ist gesperrt.",
correct: "Standwechsel ist gesperrt.",
} as const;
const MOVE_HINT =
"Beim Umsetzen an einen anderen Standort kann eine Gesundheitsbescheinigung nötig sein (BienSeuchV § 5). Im Zweifel beim Veterinäramt nachfragen.";
function sentenceOf(restriction: ActiveRestriction): string {
return `Stand ${restriction.apiaryName} liegt im Sperrbezirk (${restriction.disease}, seit ${formatDay(restriction.startsOn)}).`;
}
interface RestrictionNoticeProps {
restrictions: readonly ActiveRestriction[];
/** The sentence that says what is blocked; defaults to the move. */
blockedText?: string;
/** Lets a disabled button point at the notice (`aria-describedby`). */
id?: string;
/**
* Wraps the notice in a polite live region that stays in the page, so the
* notice is announced when it appears. Without it the notice is just a box.
*/
live?: boolean;
}
/**
* The red box: symbol, the word "Sperrbezirk" and the text in the
* `destructive` colour - never colour alone. One sentence per restriction.
*/
export function RestrictionNotice({
restrictions,
blockedText = BLOCKED_TEXT.move,
id,
live = false,
}: RestrictionNoticeProps) {
const box =
restrictions.length === 0 ? null : (
<div
id={id}
data-restriction-notice
className="flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/5 p-3 text-sm font-medium text-destructive"
>
<OctagonAlert className="mt-0.5 size-4 shrink-0" aria-hidden />
<div className="flex flex-col gap-1">
{restrictions.map((restriction) => (
<p key={restriction.id}>{`${sentenceOf(restriction)} ${blockedText}`}</p>
))}
</div>
</div>
);
return live ? <div aria-live="polite">{box}</div> : box;
}
/** Fixed hint of the move dialog; the wording is prescribed, not configurable. */
export function MoveHint() {
return <p className="text-sm text-muted-foreground">{MOVE_HINT}</p>;
}
/** The red band at the top of an apiary page while a restriction is active. */
export function RestrictionBand({
restrictions,
canOpenList,
}: {
restrictions: readonly ActiveRestriction[];
/** The list needs `compliance:read`; without it no link is offered. */
canOpenList: boolean;
}) {
if (restrictions.length === 0) return null;
return (
<section
data-restriction-band
aria-label="Sperrbezirk"
className="flex flex-col gap-3 rounded-xl border border-destructive/40 bg-destructive/5 p-3 text-sm font-medium text-destructive sm:flex-row sm:items-center sm:justify-between"
>
<div className="flex items-start gap-2">
<OctagonAlert className="mt-0.5 size-4 shrink-0" aria-hidden />
<div className="flex flex-col gap-1">
{restrictions.map((restriction) => (
<p key={restriction.id}>
{`${sentenceOf(restriction)} Völker anlegen, umsetzen oder vereinigen ist gesperrt.`}
</p>
))}
</div>
</div>
{canOpenList ? (
<Button
variant="outline"
size="touch"
render={<Link href="/compliance/restrictions" />}
>
Sperrbezirke ansehen
</Button>
) : null}
</section>
);
}
/**
* Honey and combs from a restricted zone carry conditions. Only a warning,
* never a block: the harvest is saved all the same.
*/
export function HarvestRestrictionWarning({ apiaryId }: { apiaryId: string }) {
const { restrictions } = useActiveRestrictions();
const restricted = apiaryId !== "" && restrictionsAmong([apiaryId], restrictions).length > 0;
return (
<div aria-live="polite">
{restricted ? (
<p className="flex items-start gap-2 rounded-lg bg-warning/15 p-3 text-sm">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning" aria-hidden />
<span>Im Sperrbezirk gelten Auflagen auch für Honig und Waben. Verfügung prüfen.</span>
</p>
) : null}
</div>
);
}
@@ -0,0 +1,96 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { RestrictionOverride } from "./restriction-override";
let permissions = ["compliance:manage"];
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ hasPermission: (key: string) => permissions.includes(key) }),
}));
const REASON = "Amtstierarzt hat die Ausnahme mündlich erlaubt.";
async function open() {
await userEvent.click(screen.getByRole("button", { name: /Ausnahme mit Begründung/ }));
}
describe("RestrictionOverride", () => {
beforeEach(() => {
permissions = ["compliance:manage"];
});
it("renders nothing without compliance:manage", () => {
permissions = ["compliance:read"];
const { container } = render(<RestrictionOverride onReason={vi.fn()} />);
expect(container).toBeEmptyDOMElement();
});
it("is folded until opened", async () => {
render(<RestrictionOverride onReason={vi.fn()} />);
const toggle = screen.getByRole("button", { name: /Ausnahme mit Begründung/ });
expect(toggle).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByLabelText(/Begründung der Ausnahme/)).not.toBeInTheDocument();
await open();
expect(toggle).toHaveAttribute("aria-expanded", "true");
expect(screen.getByLabelText(/Begründung der Ausnahme/)).toBeInTheDocument();
});
it("keeps the confirm button off below 10 characters and reports nothing", async () => {
const onReason = vi.fn();
render(<RestrictionOverride onReason={onReason} />);
await open();
await userEvent.type(screen.getByLabelText(/Begründung der Ausnahme/), "zu kurz");
expect(screen.getByRole("button", { name: "Ausnahme nutzen" })).toBeDisabled();
expect(onReason).not.toHaveBeenCalled();
});
it("reports the trimmed reason only after the confirmation, and takes it back", async () => {
const onReason = vi.fn();
render(<RestrictionOverride onReason={onReason} />);
await open();
await userEvent.type(screen.getByLabelText(/Begründung der Ausnahme/), ` ${REASON} `);
await userEvent.click(screen.getByRole("button", { name: "Ausnahme nutzen" }));
const dialog = await screen.findByRole("alertdialog");
expect(onReason).not.toHaveBeenCalled();
await userEvent.click(within(dialog).getByRole("button", { name: "Ausnahme bestätigen" }));
expect(onReason).toHaveBeenLastCalledWith(REASON);
expect(screen.getByRole("status")).toHaveTextContent(REASON);
await userEvent.click(screen.getByRole("button", { name: "Ausnahme zurücknehmen" }));
expect(onReason).toHaveBeenLastCalledWith(null);
});
it("keeps the confirm button off for a reason of only blanks", async () => {
render(<RestrictionOverride onReason={vi.fn()} />);
await open();
await userEvent.type(screen.getByLabelText(/Begründung der Ausnahme/), " ");
expect(screen.getByRole("button", { name: "Ausnahme nutzen" })).toBeDisabled();
});
it("caps the reason at 500 characters", async () => {
render(<RestrictionOverride onReason={vi.fn()} />);
await open();
const field = screen.getByLabelText(/Begründung der Ausnahme/) as HTMLTextAreaElement;
expect(field).toHaveAttribute("maxlength", "500");
await userEvent.click(field);
await userEvent.paste("x".repeat(501));
expect(field.value).toHaveLength(500);
});
it("does not call onReason while the confirmation is still open", async () => {
const onReason = vi.fn();
render(<RestrictionOverride onReason={onReason} />);
await open();
await userEvent.type(screen.getByLabelText(/Begründung der Ausnahme/), REASON);
await userEvent.click(screen.getByRole("button", { name: "Ausnahme nutzen" }));
await screen.findByRole("alertdialog");
await userEvent.click(screen.getByRole("button", { name: "Abbrechen" }));
expect(onReason).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,106 @@
"use client";
import { useState } from "react";
import { ChevronDown, ShieldCheck } from "lucide-react";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { Field } from "@/components/form/field";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { useAuth } from "@/lib/auth/auth-context";
import { cn } from "@/lib/utils";
/** The server's bounds for `restrictionOverrideReason`. */
export const MIN_OVERRIDE_REASON_LENGTH = 10;
export const MAX_OVERRIDE_REASON_LENGTH = 500;
interface RestrictionOverrideProps {
/** The confirmed reason, or `null` while there is none (or it was taken back). */
onReason: (reason: string | null) => void;
}
/**
* The exception from a restricted zone: a folded section with a reason that
* counts only after a second question. Only with `compliance:manage` - the
* server answers 403 to anyone else, so the field is not offered at all.
*/
export function RestrictionOverride({ onReason }: RestrictionOverrideProps) {
const { hasPermission } = useAuth();
const [open, setOpen] = useState(false);
const [text, setText] = useState("");
const [confirmed, setConfirmed] = useState<string | null>(null);
if (!hasPermission("compliance:manage")) return null;
const trimmed = text.trim();
const tooShort = trimmed.length < MIN_OVERRIDE_REASON_LENGTH;
function takeBack() {
setConfirmed(null);
onReason(null);
}
return (
<div className="flex flex-col gap-3 rounded-lg border border-border p-3">
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((current) => !current)}
className="flex min-h-11 w-full items-center justify-between gap-3 rounded-lg text-left text-sm font-medium"
>
Ausnahme mit Begründung
<ChevronDown
aria-hidden
className={cn("size-4 motion-safe:transition-transform", open && "rotate-180")}
/>
</button>
{open ? (
<div className="flex flex-col gap-3">
<Field
label="Begründung der Ausnahme"
required
hint={`Mindestens ${MIN_OVERRIDE_REASON_LENGTH} Zeichen. Sie wird in der Historie festgehalten.`}
>
{({ id, describedBy }) => (
<Textarea
id={id}
aria-describedby={describedBy}
maxLength={MAX_OVERRIDE_REASON_LENGTH}
readOnly={confirmed !== null}
value={text}
onChange={(event) => setText(event.target.value)}
/>
)}
</Field>
{confirmed === null ? (
<ConfirmDialog
triggerLabel="Ausnahme nutzen"
triggerVariant="outline"
triggerSize="touch"
triggerDisabled={tooShort}
title="Ausnahme vom Sperrbezirk nutzen?"
description="Du setzt dich über den Sperrbezirk hinweg. Deine Begründung wird mit deinem Namen festgehalten. Nutze die Ausnahme nur nach Rücksprache mit dem Veterinäramt."
confirmLabel="Ausnahme bestätigen"
destructive={false}
onConfirm={() => {
setConfirmed(trimmed);
onReason(trimmed);
}}
/>
) : (
<Button type="button" variant="outline" size="touch" onClick={takeBack}>
Ausnahme zurücknehmen
</Button>
)}
</div>
) : null}
<p role="status" className="flex items-start gap-2 text-sm font-medium">
{confirmed !== null ? (
<>
<ShieldCheck className="mt-0.5 size-4 shrink-0" aria-hidden />
<span>{`Ausnahme gilt: ${confirmed}`}</span>
</>
) : null}
</p>
</div>
);
}
@@ -24,6 +24,8 @@ interface ConfirmDialogProps {
triggerSize?: VariantProps<typeof buttonVariants>["size"]; triggerSize?: VariantProps<typeof buttonVariants>["size"];
triggerClassName?: string; triggerClassName?: string;
triggerDisabled?: boolean; triggerDisabled?: boolean;
/** Off for now but still reachable by keyboard, so `triggerDescribedBy` can be read. */
triggerAriaDisabled?: boolean;
/** Id of a hint that explains why the trigger is off (e.g. „Nur mit Verbindung“). */ /** Id of a hint that explains why the trigger is off (e.g. „Nur mit Verbindung“). */
triggerDescribedBy?: string; triggerDescribedBy?: string;
title: string; title: string;
@@ -41,6 +43,7 @@ export function ConfirmDialog({
triggerSize = "default", triggerSize = "default",
triggerClassName, triggerClassName,
triggerDisabled, triggerDisabled,
triggerAriaDisabled,
triggerDescribedBy, triggerDescribedBy,
title, title,
description, description,
@@ -60,8 +63,11 @@ export function ConfirmDialog({
className={cn(triggerClassName)} className={cn(triggerClassName)}
aria-label={triggerAriaLabel} aria-label={triggerAriaLabel}
disabled={triggerDisabled} disabled={triggerDisabled}
aria-disabled={triggerAriaDisabled || undefined}
aria-describedby={triggerDescribedBy} aria-describedby={triggerDescribedBy}
onClick={() => setOpen(true)} onClick={() => {
if (!triggerAriaDisabled) setOpen(true);
}}
> >
{triggerLabel} {triggerLabel}
</Button> </Button>
@@ -41,6 +41,7 @@ const CATEGORY_LABEL: Record<NotificationCategory, string> = {
WITHDRAWAL: "Wartezeit", WITHDRAWAL: "Wartezeit",
SEASON: "Saison", SEASON: "Saison",
SYSTEM: "System", SYSTEM: "System",
COMPLIANCE: "Behörden",
}; };
/** Same rule as the backend (`assertAppLink`): an internal path, never "//host", "/\host" or a scheme. */ /** Same rule as the backend (`assertAppLink`): an internal path, never "//host", "/\host" or a scheme. */
@@ -0,0 +1,126 @@
import { ReactNode } from "react";
import { 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";
import { lot, medicine } from "@/lib/medicines/fixtures.spec-helper";
import type { MedicineChoice } from "@/lib/treatments/use-medicine-choice";
import { BookingNotes, MedicineLotFields } from "./medicine-lot-fields";
vi.mock("@/lib/weather/api", () => ({ fetchApiaryWeather: vi.fn() }));
const STATUS = { isLoading: false, isError: false, isRetrying: false, retry: vi.fn() };
function choiceOf(overrides: Partial<MedicineChoice> = {}): MedicineChoice {
return {
canRead: true,
medicines: [medicine({ id: "m1", name: "Apivar" })],
allMedicines: [medicine({ id: "m1", name: "Apivar" })],
medicinesStatus: STATUS,
medicine: undefined,
lots: undefined,
lotsStatus: STATUS,
lot: undefined,
preselectedLotId: null,
decideLot: vi.fn(),
undecideLot: vi.fn(),
...overrides,
};
}
function wrap(node: ReactNode) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={client}>{node}</QueryClientProvider>);
}
function fields(props: Partial<React.ComponentProps<typeof MedicineLotFields>>) {
return (
<MedicineLotFields
idPrefix="t"
choice={choiceOf()}
product=""
medicineId=""
medicineLotId=""
doseQuantity=""
filled={{}}
onProductChange={vi.fn()}
onMedicineChange={vi.fn()}
onLotChange={vi.fn()}
onDoseChange={vi.fn()}
{...props}
/>
);
}
describe("MedicineLotFields", () => {
it("hands the chosen catalogue medicine to the form", async () => {
const onMedicineChange = vi.fn();
wrap(fields({ onMedicineChange }));
await userEvent.click(screen.getByRole("combobox", { name: /Aus dem Katalog/ }));
await userEvent.click(await screen.findByRole("option", { name: "Apivar" }));
expect(onMedicineChange).toHaveBeenCalledWith(expect.objectContaining({ id: "m1", name: "Apivar" }));
});
it("hands null to the form for the last option", async () => {
const onMedicineChange = vi.fn();
wrap(
fields({
onMedicineChange,
medicineId: "m1",
choice: choiceOf({ medicine: medicine({ id: "m1", name: "Apivar" }) }),
}),
);
await userEvent.click(screen.getByRole("combobox", { name: /Aus dem Katalog/ }));
await userEvent.click(await screen.findByRole("option", { name: "Anderes Mittel" }));
expect(onMedicineChange).toHaveBeenCalledWith(null);
});
it("names the unit of the medicine next to the dose", () => {
const apivar = medicine({ id: "m1", name: "Apivar", unit: "PIECE" });
wrap(fields({ choice: choiceOf({ medicine: apivar }), medicineId: "m1", doseQuantity: "2" }));
expect(screen.getByText("Stück")).toBeInTheDocument();
expect(screen.getByLabelText(/Dosis/)).toHaveValue("2");
});
});
describe("BookingNotes", () => {
const pack = lot({ unit: "ML", remaining: 100, expiresOn: "2026-06-30" });
it("shows nothing without a pack", () => {
wrap(
<BookingNotes
choice={choiceOf()}
doseQuantity="30"
colonyCount={1}
treatmentDay="2026-08-01"
apiaryId={null}
canReadApiaries={false}
/>,
);
expect(screen.queryByText(/Bucht/)).not.toBeInTheDocument();
});
it("books, warns about expiry and shortage and says saving stays possible", () => {
wrap(
<BookingNotes
choice={choiceOf({ lot: pack })}
doseQuantity="30"
colonyCount={5}
treatmentDay="2026-08-01"
apiaryId={null}
canReadApiaries={false}
/>,
);
expect(screen.getByText("Bucht 30 ml je Volk ab (× 5 = 150 ml).")).toBeInTheDocument();
expect(screen.getByText(/abgelaufen/)).toBeInTheDocument();
expect(screen.getByText(/reicht nicht: Rest 100 ml, gebraucht 150 ml/)).toBeInTheDocument();
expect(screen.getByText("Speichern ist trotzdem möglich.")).toBeInTheDocument();
});
});
@@ -0,0 +1,358 @@
"use client";
import { AlertTriangle, PackageMinus, Thermometer } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { toCalendarDay, todayLocalIsoDate } from "@/lib/dates";
import { MEDICINE_UNIT_LABELS } from "@/lib/medicines/format";
import type { LotView, MedicineView } from "@/lib/medicines/types";
import { fetchApiaryWeather } from "@/lib/weather/api";
import { bookingLine } from "@/lib/treatments/booking-line";
import { parsedDose } from "@/lib/treatments/dose";
import { lotLabel, lotWarnings } from "@/lib/treatments/lot-text";
import { temperatureHint } from "@/lib/treatments/temperature-hint";
import type { CatalogueFilled } from "@/lib/treatments/use-catalogue-prefill";
import type { MedicineChoice } from "@/lib/treatments/use-medicine-choice";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Field } from "@/components/form/field";
import { RecordSelect } from "@/components/form/record-select";
import { PrefillStatus } from "@/components/warnings/prefill-status";
const OTHER_MEDICINE = { id: "__other__", name: "Anderes Mittel" } as const;
const NO_LOT_LABEL = "Ohne Packung (nichts abbuchen)";
type MedicineOption = Pick<MedicineView, "id" | "name">;
/** Visible note that a value comes from the catalogue and is still editable. */
export function CatalogueNote({ id, shown }: { id: string; shown: boolean | undefined }) {
if (!shown) return null;
return (
<p id={`${id}-catalogue`} className="text-xs font-medium text-muted-foreground">
aus Katalog
</p>
);
}
/** The control's aria-describedby, extended by the catalogue note when one is shown. */
export function describedByCatalogue(
id: string,
describedBy: string | undefined,
shown: boolean | undefined,
): string | undefined {
return [describedBy, shown ? `${id}-catalogue` : undefined].filter(Boolean).join(" ") || undefined;
}
export interface MedicineLotFieldsProps {
idPrefix: string;
choice: MedicineChoice;
product: string;
medicineId: string;
medicineLotId: string;
doseQuantity: string;
productError?: string;
doseError?: string;
/** Which fields carry the note „aus Katalog“. */
filled: CatalogueFilled;
onProductChange: (product: string) => void;
/** `null` is „Anderes Mittel“. */
onMedicineChange: (medicine: MedicineView | null) => void;
/** `""` is „Ohne Packung“. */
onLotChange: (lotId: string) => void;
onDoseChange: (dose: string) => void;
}
/**
* „Mittel“ of a treatment: a catalogue medicine (when the user may read the
* catalogue) or any other by name, then its pack and the dose per colony.
* Everything that is chosen here stays visible and editable.
*/
export function MedicineLotFields({
idPrefix,
choice,
product,
medicineId,
medicineLotId,
doseQuantity,
productError,
doseError,
filled,
onProductChange,
onMedicineChange,
onLotChange,
onDoseChange,
}: MedicineLotFieldsProps) {
const options: MedicineOption[] = [...(choice.medicines ?? []), OTHER_MEDICINE];
const labelOptions: MedicineOption[] = [...(choice.allMedicines ?? []), OTHER_MEDICINE];
const status = choice.medicinesStatus;
return (
<>
<Field label="Aus dem Katalog wählen" id={`${idPrefix}-medicine`}>
{({ id, describedBy }) => (
<>
<RecordSelect
id={id}
describedBy={describedBy}
value={medicineId || OTHER_MEDICINE.id}
onChange={(next) =>
onMedicineChange(
choice.medicines?.find((candidate) => candidate.id === next) ?? null,
)
}
items={options}
labelItems={labelOptions}
getLabel={(option) => option.name}
/>
<PrefillStatus
enabled={choice.canRead}
isLoading={status.isLoading}
isError={status.isError}
isRetrying={status.isRetrying}
onRetry={status.retry}
showLoading={false}
/>
</>
)}
</Field>
{medicineId === "" ? (
<Field
label="Mittel"
required
error={productError}
hint="Handelsname, z. B. „Oxuvar“"
>
{({ id, describedBy, invalid }) => (
<Input
id={id}
data-field="product"
required
className="h-11"
aria-describedby={describedBy}
aria-invalid={invalid}
value={product}
onChange={(event) => onProductChange(event.target.value)}
/>
)}
</Field>
) : (
<>
<LotField
idPrefix={idPrefix}
choice={choice}
medicineLotId={medicineLotId}
onLotChange={onLotChange}
/>
<DoseField
idPrefix={idPrefix}
medicine={choice.medicine}
hasLot={medicineLotId !== ""}
value={doseQuantity}
error={doseError}
isFromCatalogue={filled.doseQuantity}
onChange={onDoseChange}
/>
</>
)}
</>
);
}
function LotField({
idPrefix,
choice,
medicineLotId,
onLotChange,
}: {
idPrefix: string;
choice: MedicineChoice;
medicineLotId: string;
onLotChange: (lotId: string) => void;
}) {
const { lots, lotsStatus } = choice;
const isEmpty = lots !== undefined && lots.length === 0;
return (
<Field label="Packung" id={`${idPrefix}-lot`}>
{({ id, describedBy }) => (
<>
<RecordSelect
id={id}
describedBy={describedBy}
value={medicineLotId}
onChange={onLotChange}
items={lots}
getLabel={(lot: LotView) => lotLabel(lot)}
noneLabel={NO_LOT_LABEL}
dataField="medicineLotId"
/>
<div role="status" aria-live="polite" className="empty:hidden">
{lotsStatus.isLoading ? (
<p className="text-sm text-muted-foreground">Packungen werden geladen …</p>
) : null}
{choice.preselectedLotId ? (
<p className="text-xs font-medium text-muted-foreground">
Die Packung mit dem frühesten Verfall ist vorgewählt.
</p>
) : null}
{isEmpty ? (
<p className="text-sm text-muted-foreground">
Keine Packung mit Rest im Katalog. Du kannst trotzdem speichern, dann wird nichts
abgebucht.
</p>
) : null}
</div>
{lotsStatus.isError ? (
<div className="flex flex-wrap items-center gap-2 rounded-lg bg-warning/20 px-3 py-2 text-sm">
<span>Packungen konnten nicht geladen werden.</span>
<Button
type="button"
variant="outline"
size="touch"
onClick={lotsStatus.retry}
disabled={lotsStatus.isRetrying}
>
Erneut versuchen
</Button>
</div>
) : null}
</>
)}
</Field>
);
}
function DoseField({
idPrefix,
medicine,
hasLot,
value,
error,
isFromCatalogue,
onChange,
}: {
idPrefix: string;
medicine: MedicineView | undefined;
hasLot: boolean;
value: string;
error: string | undefined;
isFromCatalogue: boolean | undefined;
onChange: (dose: string) => void;
}) {
const unit = medicine ? MEDICINE_UNIT_LABELS[medicine.unit] : null;
const hint = hasLot
? "Je Volk. Ohne Dosis wird nichts abgebucht."
: "Je Volk, wie auf der Packungsbeilage.";
return (
<Field label="Dosis je Volk" id={`${idPrefix}-dose`} hint={hint} error={error}>
{({ id, describedBy, invalid }) => (
<>
<div className="flex items-center gap-2">
<Input
id={id}
data-field="doseQuantity"
inputMode="decimal"
autoComplete="off"
className="h-11 w-28"
aria-describedby={describedByCatalogue(id, describedBy, isFromCatalogue)}
aria-invalid={invalid}
value={value}
onChange={(event) => onChange(event.target.value)}
/>
{unit ? <span className="text-sm text-muted-foreground">{unit}</span> : null}
</div>
<CatalogueNote id={id} shown={isFromCatalogue} />
</>
)}
</Field>
);
}
export interface BookingNotesProps {
choice: MedicineChoice;
doseQuantity: string;
/** How many colonies the dose is booked for (1 for a single capture). */
colonyCount: number;
/** The treatment day "YYYY-MM-DD"; an expired pack is judged on it. */
treatmentDay: string;
/** The apiary the weather of the temperature hint belongs to. */
apiaryId: string | null;
canReadApiaries: boolean;
}
/**
* What saving does, above „Speichern“: the booking line, warnings about the
* pack (expired, too little) that never block, and the temperature hint. The
* region stays mounted so a change is announced.
*/
export function BookingNotes({
choice,
doseQuantity,
colonyCount,
treatmentDay,
apiaryId,
canReadApiaries,
}: BookingNotesProps) {
const dose = parsedDose(doseQuantity);
const { lot, medicine } = choice;
const line = bookingLine(dose, lot ? lot.unit : null, colonyCount);
const warnings = lot ? lotWarnings(lot, dose, colonyCount, treatmentDay) : [];
return (
<div role="status" aria-live="polite" className="flex flex-col gap-1 empty:hidden">
{line ? (
<p className="flex items-start gap-2 text-sm">
<PackageMinus className="mt-0.5 size-4 shrink-0" aria-hidden />
<span>{line}</span>
</p>
) : null}
{warnings.map((warning) => (
<p key={warning} className="flex items-start gap-2 text-sm font-medium">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning" aria-hidden />
<span>{warning}</span>
</p>
))}
{warnings.length > 0 ? (
<p className="text-xs text-muted-foreground">Speichern ist trotzdem möglich.</p>
) : null}
<TemperatureNote
medicine={medicine}
apiaryId={apiaryId}
canReadApiaries={canReadApiaries}
/>
</div>
);
}
function TemperatureNote({
medicine,
apiaryId,
canReadApiaries,
}: {
medicine: MedicineView | undefined;
apiaryId: string | null;
canReadApiaries: boolean;
}) {
const hasWindow = medicine !== undefined &&
(medicine.minTemperatureC !== null || medicine.maxTemperatureC !== null);
const weather = useQuery({
queryKey: ["apiaries", apiaryId, "weather"],
queryFn: () => fetchApiaryWeather(apiaryId ?? ""),
enabled: canReadApiaries && apiaryId !== null && hasWindow,
retry: false,
});
if (!medicine || !hasWindow || !weather.data) return null;
const day = todayLocalIsoDate();
const forecast = weather.data.daily.find((entry) => toCalendarDay(entry.date) === day);
if (!forecast) return null;
const hint = temperatureHint(
{ min: forecast.tempMinC, max: forecast.tempMaxC },
{ min: medicine.minTemperatureC, max: medicine.maxTemperatureC },
);
if (!hint) return null;
const Icon = hint.warn ? AlertTriangle : Thermometer;
return (
<p data-warn={hint.warn} className="flex flex-wrap items-start gap-x-2 text-sm">
<Icon className={`mt-0.5 size-4 shrink-0 ${hint.warn ? "text-warning" : ""}`} aria-hidden />
<span>{hint.text}</span>
{hint.warn ? <span className="font-medium">Außerhalb des Katalog-Bereichs</span> : null}
</p>
);
}
@@ -48,6 +48,12 @@ export interface CreateColonyInput {
originText?: string | null; originText?: string | null;
occupiedFrames?: number | null; occupiedFrames?: number | null;
notes?: string | null; notes?: string | null;
/**
* Why the action may go ahead although an apiary lies in a restricted zone
* (10-500 characters, only with compliance:manage; the server answers 403
* to anyone else). Send it only when the exception is really used.
*/
restrictionOverrideReason?: string;
} }
/** `null` clears an optional field, an omitted one stays unchanged. */ /** `null` clears an optional field, an omitted one stays unchanged. */
@@ -65,11 +71,23 @@ export interface UpdateColonyInput {
healthStatus?: ColonyCondition | null; healthStatus?: ColonyCondition | null;
notes?: string | null; notes?: string | null;
status?: ColonyStatus; status?: ColonyStatus;
/**
* Why the action may go ahead although an apiary lies in a restricted zone
* (10-500 characters, only with compliance:manage; the server answers 403
* to anyone else). Send it only when the exception is really used.
*/
restrictionOverrideReason?: string;
} }
export interface MoveColonyInput { export interface MoveColonyInput {
apiaryId: string; apiaryId: string;
notes?: string; notes?: string;
/**
* Why the action may go ahead although an apiary lies in a restricted zone
* (10-500 characters, only with compliance:manage; the server answers 403
* to anyone else). Send it only when the exception is really used.
*/
restrictionOverrideReason?: string;
} }
/** Where the split colony gets its queen from - an observation, never prefilled. */ /** Where the split colony gets its queen from - an observation, never prefilled. */
@@ -88,6 +106,12 @@ export interface SplitColonyInput {
export interface MergeColonyInput { export interface MergeColonyInput {
mergedColonyIds: string[]; mergedColonyIds: string[];
notes?: string; notes?: string;
/**
* Why the action may go ahead although an apiary lies in a restricted zone
* (10-500 characters, only with compliance:manage; the server answers 403
* to anyone else). Send it only when the exception is really used.
*/
restrictionOverrideReason?: string;
} }
export interface ColonyHistory { export interface ColonyHistory {
@@ -41,7 +41,7 @@ describe("compliance errors", () => {
}); });
it("builds on saveErrorText for the text line", () => { it("builds on saveErrorText for the text line", () => {
expect(complianceErrorText(new ApiError(500, "Serverfehler"), "Fallback")).toBe("Serverfehler"); expect(complianceErrorText(new ApiError(409, "Serverfehler"), "Fallback")).toBe("Serverfehler");
expect(complianceErrorText(new Error("x"), "Fallback")).toBe("Fallback"); expect(complianceErrorText(new Error("x"), "Fallback")).toBe("Fallback");
}); });
@@ -70,3 +70,20 @@ describe("compliance errors", () => {
expect(readinessConflictText(new Error("x"))).toBeNull(); expect(readinessConflictText(new Error("x"))).toBeNull();
}); });
}); });
describe("complianceErrorText", () => {
it("shows German text for a 403 and for a server error, never Nest's English", () => {
expect(complianceErrorText(new ApiError(403, "Forbidden resource"), "x")).toBe("Dazu fehlt dir das Recht.");
expect(complianceErrorText(new ApiError(502, "Bad Gateway"), "x")).toBe(
"Der Server hat einen Fehler gemeldet. Versuch es gleich noch einmal.",
);
});
it("joins a 400 array message and keeps a plain 409 text", () => {
const backend = ["tskReferenceDay muss ein Tag im Format MM-TT sein"];
expect(complianceErrorText(new ApiError(400, "x", { message: backend }), "f")).toBe(backend[0]);
expect(complianceErrorText(new ApiError(409, "Erst die Erstanzeige erfassen."), "f")).toBe(
"Erst die Erstanzeige erfassen.",
);
});
});
+24 -1
View File
@@ -91,7 +91,30 @@ export function readinessConflictText(error: unknown): string | null {
return READINESS_CONFLICT_TEXT[code] ?? staleFingerprint(error); return READINESS_CONFLICT_TEXT[code] ?? staleFingerprint(error);
} }
/** One line for every other failure: the shared reader, no second error logic. */ const NO_RIGHT_TEXT = "Dazu fehlt dir das Recht.";
const SERVER_FAILED_TEXT = "Der Server hat einen Fehler gemeldet. Versuch es gleich noch einmal.";
/** Nest sends `message` as an array when several DTO checks fail; join the German parts. */
function arrayMessage(error: ApiError): string | null {
const message = (error.body as { message?: unknown } | null)?.message;
if (!Array.isArray(message)) return null;
const parts = message.filter((part): part is string => typeof part === "string" && part !== "");
return parts.length > 0 ? parts.join(" ") : null;
}
/**
* One line for every other failure: the shared reader, no second error logic.
* A 403 and a server error never show Nest's English text; a 400 with an array
* message shows its parts.
*/
export function complianceErrorText(error: unknown, fallback: string): string { export function complianceErrorText(error: unknown, fallback: string): string {
if (error instanceof ApiError) {
if (error.status === 403) return NO_RIGHT_TEXT;
if (error.status >= 500) return SERVER_FAILED_TEXT;
if (error.status === 400) {
const joined = arrayMessage(error);
if (joined) return joined;
}
}
return saveErrorText(error, fallback); return saveErrorText(error, fallback);
} }
@@ -51,15 +51,6 @@ describe("fixLinkFor", () => {
expect(fixLinkFor("treatment-log-gaps", { type: "treatment", id: "t1" }).available).toBe(false); expect(fixLinkFor("treatment-log-gaps", { type: "treatment", id: "t1" }).available).toBe(false);
}); });
it.each([
["registration", { type: "notice", id: "n1" }],
["restriction-active", { type: "apiary", id: "a1" }],
["vet-office", null],
["herd-change", null],
])("hides %s until its target page exists", (itemKey, subject) => {
expect(fixLinkFor(itemKey, subject).available).toBe(false);
});
it.each([ it.each([
["holder-data", null], ["holder-data", null],
["medicine-lot-gaps", { type: "lot", id: "l1" }], ["medicine-lot-gaps", { type: "lot", id: "l1" }],
@@ -67,13 +58,22 @@ describe("fixLinkFor", () => {
["label-gaps", { type: "template", id: "x1" }], ["label-gaps", { type: "template", id: "x1" }],
["harvest-withdrawal", { type: "harvest", id: "h1" }], ["harvest-withdrawal", { type: "harvest", id: "h1" }],
["medicine-withdrawal-missing", null], ["medicine-withdrawal-missing", null],
["registration", { type: "notice", id: "n1" }],
["restriction-active", { type: "apiary", id: "a1" }],
["vet-office", null],
["herd-change", null],
])("shows %s, its page exists", (itemKey, subject) => { ])("shows %s, its page exists", (itemKey, subject) => {
expect(fixLinkFor(itemKey, subject).available).toBe(true); expect(fixLinkFor(itemKey, subject).available).toBe(true);
}); });
it("is switched on per route in one set", () => { it("is switched on per route in one set", () => {
expect(ROUTE_AVAILABLE.has("/compliance/notices")).toBe(false); for (const route of [
expect(ROUTE_AVAILABLE.has("/compliance/restrictions")).toBe(false); "/compliance/notices",
expect(ROUTE_AVAILABLE.has("/compliance/settings")).toBe(false); "/compliance/notices/[id]",
"/compliance/restrictions",
"/compliance/settings",
]) {
expect(ROUTE_AVAILABLE.has(route)).toBe(true);
}
}); });
}); });
@@ -2,11 +2,14 @@ const FIX_LABEL = "Beheben";
/** /**
* The pages that exist. A „Beheben“ link to any other route is hidden, so * The pages that exist. A „Beheben“ link to any other route is hidden, so
* nobody lands on a 404. Tasks 18 and 19 add their routes here to switch the * nobody lands on a 404. A new page adds its route here to switch the links
* links on: `/compliance/notices`, `/compliance/notices/[id]`, * on.
* `/compliance/restrictions` and `/compliance/settings`.
*/ */
export const ROUTE_AVAILABLE: ReadonlySet<string> = new Set([ export const ROUTE_AVAILABLE: ReadonlySet<string> = new Set([
"/compliance/notices",
"/compliance/notices/[id]",
"/compliance/restrictions",
"/compliance/settings",
"/colonies/[id]", "/colonies/[id]",
"/harvests/[id]", "/harvests/[id]",
"/medicines", "/medicines",
@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { isValidMonthDay } from "./month-day";
describe("isValidMonthDay", () => {
it.each(["01-01", "12-31", "02-28", "04-30"])("accepts %s", (value) => {
expect(isValidMonthDay(value)).toBe(true);
});
it.each(["02-29", "04-31", "13-01", "00-10", "1-1", "01-1", "2027-01-01", "", " 01-01"])("rejects %s", (value) => {
expect(isValidMonthDay(value)).toBe(false);
});
});
@@ -0,0 +1,13 @@
/** Days per month in a non-leap year, as the backend counts them (02-29 does not exist). */
const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
const MONTH_DAY = /^(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/;
export const MONTH_DAY_PROBLEM =
"Bitte einen Tag im Format MM-TT eingeben, zum Beispiel 03-31. Den 29. Februar gibt es hier nicht.";
/** MM-TT with a real month and a day that exists in a non-leap year (mirrors the backend). */
export function isValidMonthDay(value: string): boolean {
const match = MONTH_DAY.exec(value);
if (!match) return false;
return Number(match[2]) <= DAYS_IN_MONTH[Number(match[1]) - 1];
}
@@ -0,0 +1,56 @@
import type { AuthorityNotice, ComplianceSettings } from "./notice-types";
/** Test data for notices and settings; override only what a test is about. */
export function noticeFixture(overrides: Partial<AuthorityNotice> = {}): AuthorityNotice {
return {
id: "n1",
kind: "HERD_CHANGE",
status: "DRAFT",
year: null,
referenceDate: "2027-01-10T00:00:00.000Z",
countedAt: "2027-01-10T08:00:00.000Z",
snapshot: {
version: 1,
takenAt: "2027-01-10T08:00:00.000Z",
totalLiving: 7,
apiaries: [
{
apiaryId: "a1",
name: "Waldrand",
address: "Dorfstraße 1",
location: "12345 Musterdorf",
latitude: null,
longitude: null,
livingColonies: 7,
byType: { PRODUCTION_COLONY: 5, NUCLEUS_COLONY: 2 },
},
],
},
totalColonies: 7,
sentOn: null,
sentVia: null,
reference: null,
notes: null,
createdAt: "2027-01-10T08:00:00.000Z",
...overrides,
};
}
export function settingsFixture(overrides: Partial<ComplianceSettings> = {}): ComplianceSettings {
return {
veterinaryOfficeName: "Veterinäramt Musterstadt",
veterinaryOfficeAddress: "Amtsweg 2, 12345 Musterstadt",
registrationNumber: null,
noticeOnColonyCountChange: true,
noticeOnApiaryChange: true,
tskReferenceDay: "01-01",
tskReportDueDay: null,
tskMemberNumber: null,
tskExcludedColonyTypes: [],
netQuantityDigitRules: [
{ maxGrams: 100, minMm: 1.5 },
{ maxGrams: null, minMm: 2.5 },
],
...overrides,
};
}
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { NOTICE_KIND_LABEL, NOTICE_STATUS_LABEL, noticeDay, noticeTitle } from "./notice-labels";
import { noticeFixture } from "./notice-fixtures";
describe("notice labels", () => {
it("names every kind and status in German", () => {
expect(NOTICE_KIND_LABEL).toEqual({
REGISTRATION: "Erstanzeige",
HERD_CHANGE: "Bestandsänderung",
TSK_REPORT: "TSK-Zählung",
});
expect(NOTICE_STATUS_LABEL).toEqual({ DRAFT: "Entwurf", SENT: "Gesendet", CANCELLED: "Storniert" });
});
it("reads a day from an ISO date-time as written, without a time zone shift", () => {
expect(noticeDay("2027-01-10T00:00:00.000Z")).toBe("2027-01-10");
expect(noticeDay(null)).toBeNull();
});
it("titles a TSK count with its year", () => {
expect(noticeTitle(noticeFixture({ kind: "TSK_REPORT", year: 2027 }))).toBe("TSK-Zählung 2027");
expect(noticeTitle(noticeFixture({ kind: "REGISTRATION" }))).toBe("Erstanzeige");
});
});
@@ -0,0 +1,25 @@
import type { AuthorityNotice, AuthorityNoticeKind, AuthorityNoticeStatus } from "./notice-types";
export const NOTICE_KIND_LABEL: Record<AuthorityNoticeKind, string> = {
REGISTRATION: "Erstanzeige",
HERD_CHANGE: "Bestandsänderung",
TSK_REPORT: "TSK-Zählung",
};
export const NOTICE_KINDS: readonly AuthorityNoticeKind[] = ["REGISTRATION", "HERD_CHANGE", "TSK_REPORT"];
export const NOTICE_STATUS_LABEL: Record<AuthorityNoticeStatus, string> = {
DRAFT: "Entwurf",
SENT: "Gesendet",
CANCELLED: "Storniert",
};
/** The calendar day of an ISO date-time as written ("2027-01-10"); no time zone shift. */
export function noticeDay(value: string | null): string | null {
return value ? value.slice(0, 10) : null;
}
export function noticeTitle(notice: Pick<AuthorityNotice, "kind" | "year">): string {
const label = NOTICE_KIND_LABEL[notice.kind];
return notice.year === null ? label : `${label} ${notice.year}`;
}
@@ -0,0 +1,85 @@
import type { ColonyType } from "@/lib/colonies/types";
// Mirrors the backend (ApiCore-Backend/src/compliance: AuthorityNotice, HerdSnapshot, ComplianceSettings).
export type AuthorityNoticeKind = "REGISTRATION" | "HERD_CHANGE" | "TSK_REPORT";
export type AuthorityNoticeStatus = "DRAFT" | "SENT" | "CANCELLED";
export interface HerdApiarySnapshot {
apiaryId: string;
name: string;
address: string | null;
location: string | null;
latitude: number | null;
longitude: number | null;
livingColonies: number;
byType: Partial<Record<ColonyType, number>>;
}
export interface HerdSnapshot {
version: number;
takenAt: string;
apiaries: HerdApiarySnapshot[];
totalLiving: number;
}
/** Days arrive as ISO date-times ("2027-01-01T00:00:00.000Z"); `noticeDay` reads the day. */
export interface AuthorityNotice {
id: string;
kind: AuthorityNoticeKind;
status: AuthorityNoticeStatus;
year: number | null;
referenceDate: string;
countedAt: string;
snapshot: HerdSnapshot;
totalColonies: number;
sentOn: string | null;
sentVia: string | null;
reference: string | null;
notes: string | null;
createdAt: string;
}
export interface CreateNoticeInput {
kind: AuthorityNoticeKind;
year?: number;
referenceDate?: string;
sentOn?: string;
}
export type NoticeAction = "recount" | "mark-sent" | "reopen" | "cancel";
export interface MarkSentInput {
sentOn?: string;
sentVia?: string;
reference?: string;
}
export interface NoticeEdit {
totalColonies: number;
referenceDate: string;
sentOn: string | null;
sentVia: string | null;
reference: string | null;
notes: string | null;
}
export interface NetQuantityDigitRule {
/** Upper limit in grams; `null` = everything above. */
maxGrams: number | null;
minMm: number;
}
export interface ComplianceSettings {
veterinaryOfficeName: string | null;
veterinaryOfficeAddress: string | null;
registrationNumber: string | null;
noticeOnColonyCountChange: boolean;
noticeOnApiaryChange: boolean;
/** MM-TT. */
tskReferenceDay: string;
tskReportDueDay: string | null;
tskMemberNumber: string | null;
tskExcludedColonyTypes: ColonyType[];
netQuantityDigitRules: NetQuantityDigitRule[];
}
@@ -0,0 +1,70 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { apiFetch, apiFetchBlob } from "@/lib/auth/api-client";
import { downloadBlob } from "@/lib/export/api";
import {
createNotice,
fetchNotice,
fetchNotices,
noticeAction,
openNoticePdf,
updateNotice,
} from "./notices-api";
vi.mock("@/lib/auth/api-client", () => ({ apiFetch: vi.fn(), apiFetchBlob: vi.fn() }));
vi.mock("@/lib/export/api", () => ({ downloadBlob: vi.fn() }));
describe("notices api", () => {
beforeEach(() => {
vi.mocked(apiFetch).mockReset();
vi.mocked(apiFetch).mockResolvedValue(undefined);
});
it("lists with the kind and year filter only when given", async () => {
await fetchNotices();
await fetchNotices({ kind: "TSK_REPORT", year: 2027 });
expect(apiFetch).toHaveBeenNthCalledWith(1, "/authority-notices");
expect(apiFetch).toHaveBeenNthCalledWith(2, "/authority-notices?kind=TSK_REPORT&year=2027");
});
it("reads one notice by encoded id", async () => {
await fetchNotice("a/1");
expect(apiFetch).toHaveBeenCalledWith("/authority-notices/a%2F1");
});
it("creates with the given idempotency key", async () => {
await createNotice({ kind: "REGISTRATION", sentOn: "2026-05-01" }, { idempotencyKey: "key-1" });
const [path, init] = vi.mocked(apiFetch).mock.calls[0];
expect(path).toBe("/authority-notices");
expect(init).toMatchObject({
method: "POST",
body: JSON.stringify({ kind: "REGISTRATION", sentOn: "2026-05-01" }),
headers: { "Idempotency-Key": "key-1" },
});
});
it("posts an action; mark-sent carries date, channel and reference", async () => {
await noticeAction("n1", "recount");
await noticeAction("n1", "mark-sent", { sentOn: "2027-01-15", sentVia: "E-Mail", reference: "AZ 1" });
expect(apiFetch).toHaveBeenNthCalledWith(1, "/authority-notices/n1/recount", { method: "POST" });
expect(apiFetch).toHaveBeenNthCalledWith(2, "/authority-notices/n1/mark-sent", {
method: "POST",
body: JSON.stringify({ sentOn: "2027-01-15", sentVia: "E-Mail", reference: "AZ 1" }),
});
});
it("patches the edit", async () => {
await updateNotice("n1", { totalColonies: 9, notes: null });
expect(apiFetch).toHaveBeenCalledWith("/authority-notices/n1", {
method: "PATCH",
body: JSON.stringify({ totalColonies: 9, notes: null }),
});
});
it("downloads the pdf over the authenticated client, never through a link with a token", async () => {
const blob = new Blob(["x"]);
vi.mocked(apiFetchBlob).mockResolvedValue({ blob, filename: "anzeige-n1.pdf" });
await openNoticePdf("n1");
expect(apiFetchBlob).toHaveBeenCalledWith("/authority-notices/n1/pdf", "anzeige-n1.pdf");
expect(downloadBlob).toHaveBeenCalledWith(blob, "anzeige-n1.pdf");
});
});
@@ -0,0 +1,55 @@
import { apiFetch, apiFetchBlob } from "@/lib/auth/api-client";
import { downloadBlob } from "@/lib/export/api";
import { type CreateOptions, idempotencyHeaders } from "@/lib/offline/idempotency";
import type {
AuthorityNotice,
AuthorityNoticeKind,
CreateNoticeInput,
MarkSentInput,
NoticeAction,
NoticeEdit,
} from "./notice-types";
const NOTICES = "/authority-notices";
function noticePath(id: string, rest = ""): string {
return `${NOTICES}/${encodeURIComponent(id)}${rest}`;
}
export function fetchNotices(query: { kind?: AuthorityNoticeKind; year?: number } = {}): Promise<AuthorityNotice[]> {
const params = new URLSearchParams();
if (query.kind) params.set("kind", query.kind);
if (query.year !== undefined) params.set("year", String(query.year));
const text = params.toString();
return apiFetch<AuthorityNotice[]>(text ? `${NOTICES}?${text}` : NOTICES);
}
export function fetchNotice(id: string): Promise<AuthorityNotice> {
return apiFetch<AuthorityNotice>(noticePath(id));
}
/** 409 `DRAFT_EXISTS` carries `existingId`; read it with `draftExists` from errors.ts. */
export function createNotice(input: CreateNoticeInput, options?: CreateOptions): Promise<AuthorityNotice> {
return apiFetch<AuthorityNotice>(NOTICES, {
method: "POST",
headers: idempotencyHeaders(options),
body: JSON.stringify(input),
});
}
export function noticeAction(id: string, action: NoticeAction, body?: MarkSentInput): Promise<AuthorityNotice> {
return apiFetch<AuthorityNotice>(noticePath(id, `/${action}`), {
method: "POST",
...(body ? { body: JSON.stringify(body) } : {}),
});
}
export function updateNotice(id: string, input: Partial<NoticeEdit>): Promise<AuthorityNotice> {
return apiFetch<AuthorityNotice>(noticePath(id), { method: "PATCH", body: JSON.stringify(input) });
}
/** The notice as PDF: an authenticated download, never a link that carries a token. */
export async function openNoticePdf(id: string): Promise<void> {
const { blob, filename } = await apiFetchBlob(noticePath(id, "/pdf"), `anzeige-${id}.pdf`);
downloadBlob(blob, filename);
}
@@ -0,0 +1,67 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { apiFetch } from "@/lib/auth/api-client";
import {
createRestrictions,
endRestriction,
fetchActiveRestrictions,
fetchRestrictions,
isRestrictionActive,
restrictionsAmong,
type ActiveRestriction,
} from "./restrictions-api";
vi.mock("@/lib/auth/api-client", () => ({ apiFetch: vi.fn() }));
const active = (id: string, apiaryId: string): ActiveRestriction => ({
id,
apiaryId,
apiaryName: `Stand ${apiaryId}`,
disease: "Amerikanische Faulbrut",
startsOn: "2026-10-01",
});
describe("restrictions api", () => {
beforeEach(() => {
vi.mocked(apiFetch).mockReset();
vi.mocked(apiFetch).mockResolvedValue([]);
});
it("reads the active restrictions and the full list", async () => {
await fetchActiveRestrictions();
await fetchRestrictions();
expect(apiFetch).toHaveBeenNthCalledWith(1, "/apiary-restrictions?active=true");
expect(apiFetch).toHaveBeenNthCalledWith(2, "/apiary-restrictions");
});
it("creates restrictions for several apiaries with one idempotency key", async () => {
const input = { apiaryIds: ["a-1", "a-2"], disease: "Kleiner Beutenkäfer", startsOn: "2026-10-01" };
await createRestrictions(input, { idempotencyKey: "key-1" });
const [path, init] = vi.mocked(apiFetch).mock.calls[0];
expect(path).toBe("/apiary-restrictions");
expect(init).toMatchObject({ method: "POST", body: JSON.stringify(input) });
expect((init?.headers as Record<string, string>)["Idempotency-Key"]).toBe("key-1");
});
it("ends a restriction by setting endsOn (PATCH, never DELETE)", async () => {
await endRestriction("r/1", "2026-11-30");
expect(apiFetch).toHaveBeenCalledWith("/apiary-restrictions/r%2F1", {
method: "PATCH",
body: JSON.stringify({ endsOn: "2026-11-30" }),
});
});
it("restrictionsAmong keeps only the restrictions of the named apiaries", () => {
const rows = [active("r-1", "a-1"), active("r-2", "a-2"), active("r-3", "a-1")];
expect(restrictionsAmong(["a-1"], rows).map((row) => row.id)).toEqual(["r-1", "r-3"]);
expect(restrictionsAmong([], rows)).toEqual([]);
});
it("treats the start and the end day as inside and an open end as until further notice", () => {
const row = { startsOn: "2026-10-01T00:00:00.000Z", endsOn: "2026-10-05T00:00:00.000Z" };
expect(isRestrictionActive(row, "2026-09-30")).toBe(false);
expect(isRestrictionActive(row, "2026-10-01")).toBe(true);
expect(isRestrictionActive(row, "2026-10-05")).toBe(true);
expect(isRestrictionActive(row, "2026-10-06")).toBe(false);
expect(isRestrictionActive({ ...row, endsOn: null }, "2030-01-01")).toBe(true);
});
});
@@ -0,0 +1,102 @@
import { apiFetch } from "@/lib/auth/api-client";
import { toCalendarDay } from "@/lib/dates";
import { idempotencyHeaders, type CreateOptions } from "@/lib/offline/idempotency";
/** What `GET /apiary-restrictions?active=true` returns: no authority, reference or notes. */
export interface ActiveRestriction {
id: string;
apiaryId: string;
apiaryName: string;
disease: string;
/** Calendar day "YYYY-MM-DD". */
startsOn: string;
}
/**
* A restriction row. Authority, reference, notes and creator come only with
* `compliance:read`; a reader with `apiaries:read` alone gets the rest.
*/
export interface ApiaryRestriction {
id: string;
apiaryId: string;
disease: string;
authority?: string | null;
reference?: string | null;
/** Timestamp of the start day (midnight UTC); read with `toCalendarDay`. */
startsOn: string;
/** Last day of the restriction; `null` = until further notice. */
endsOn: string | null;
notes?: string | null;
}
export interface CreateRestrictionsInput {
apiaryIds: string[];
disease: string;
authority?: string;
reference?: string;
/** Calendar day "YYYY-MM-DD". */
startsOn: string;
endsOn?: string;
notes?: string;
}
export function fetchActiveRestrictions(): Promise<ActiveRestriction[]> {
return apiFetch<ActiveRestriction[]>("/apiary-restrictions?active=true");
}
/** Every restriction, active and ended (ended ones are kept, never deleted). */
export function fetchRestrictions(): Promise<ApiaryRestriction[]> {
return apiFetch<ApiaryRestriction[]>("/apiary-restrictions");
}
/** One row per apiary; the idempotency key makes a retry after a lost answer safe. */
export function createRestrictions(
input: CreateRestrictionsInput,
options: CreateOptions = {},
): Promise<ApiaryRestriction[]> {
return apiFetch<ApiaryRestriction[]>("/apiary-restrictions", {
method: "POST",
headers: idempotencyHeaders(options),
body: JSON.stringify(input),
});
}
/** Ending sets the last day; the entry stays. */
export function endRestriction(id: string, endsOn: string): Promise<ApiaryRestriction> {
return apiFetch<ApiaryRestriction>(`/apiary-restrictions/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify({ endsOn }),
});
}
/** The restrictions of the named apiaries, in the order they were given. */
export function restrictionsAmong(
apiaryIds: readonly string[],
active: readonly ActiveRestriction[],
): ActiveRestriction[] {
const wanted = new Set(apiaryIds);
return active.filter((restriction) => wanted.has(restriction.apiaryId));
}
export type RestrictionState = "planned" | "active" | "ended";
/** Not started yet ("Geplant"), in force, or over - by the Berlin calendar day. */
export function restrictionState(
restriction: Pick<ApiaryRestriction, "startsOn" | "endsOn">,
day: string,
): RestrictionState {
if (toCalendarDay(restriction.startsOn) > day) return "planned";
return isRestrictionActive(restriction, day) ? "active" : "ended";
}
/**
* Active from the start day to the end day, both included; no end day =
* until further notice. `day` is the Berlin calendar day, as on the server.
*/
export function isRestrictionActive(
restriction: Pick<ApiaryRestriction, "startsOn" | "endsOn">,
day: string,
): boolean {
if (toCalendarDay(restriction.startsOn) > day) return false;
return restriction.endsOn === null || day <= toCalendarDay(restriction.endsOn);
}
@@ -0,0 +1,25 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { apiFetch } from "@/lib/auth/api-client";
import { fetchComplianceSettings, updateComplianceSettings } from "./settings-api";
vi.mock("@/lib/auth/api-client", () => ({ apiFetch: vi.fn() }));
describe("compliance settings api", () => {
beforeEach(() => {
vi.mocked(apiFetch).mockReset();
vi.mocked(apiFetch).mockResolvedValue(undefined);
});
it("reads the settings", async () => {
await fetchComplianceSettings();
expect(apiFetch).toHaveBeenCalledWith("/compliance/settings");
});
it("patches only what it is given, keeping explicit nulls", async () => {
await updateComplianceSettings({ tskReportDueDay: null, tskReferenceDay: "03-15" });
expect(apiFetch).toHaveBeenCalledWith("/compliance/settings", {
method: "PATCH",
body: JSON.stringify({ tskReportDueDay: null, tskReferenceDay: "03-15" }),
});
});
});
@@ -0,0 +1,12 @@
import { apiFetch } from "@/lib/auth/api-client";
import type { ComplianceSettings } from "./notice-types";
const SETTINGS = "/compliance/settings";
export function fetchComplianceSettings(): Promise<ComplianceSettings> {
return apiFetch<ComplianceSettings>(SETTINGS);
}
export function updateComplianceSettings(input: Partial<ComplianceSettings>): Promise<ComplianceSettings> {
return apiFetch<ComplianceSettings>(SETTINGS, { method: "PATCH", body: JSON.stringify(input) });
}
@@ -0,0 +1,27 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { complianceKey } from "./use-readiness";
import { fetchActiveRestrictions, type ActiveRestriction } from "./restrictions-api";
/** Under the `compliance` key, so one invalidation reloads every compliance query. */
export const activeRestrictionsKey = [...complianceKey, "restrictions", "active"] as const;
const ACTIVE_RESTRICTIONS_STALE_MS = 30_000;
const NONE: ActiveRestriction[] = [];
/**
* The active restrictions, loaded once per page (shared query key). The
* server decides in the end; this only hides a blocked action early.
*/
export function useActiveRestrictions(): { restrictions: ActiveRestriction[]; isLoading: boolean } {
const query = useQuery({
queryKey: activeRestrictionsKey,
queryFn: fetchActiveRestrictions,
retry: false,
// Short-lived: a restriction may start any minute; the server still decides.
staleTime: ACTIVE_RESTRICTIONS_STALE_MS,
refetchOnWindowFocus: true,
});
return { restrictions: query.data ?? NONE, isLoading: query.isLoading };
}
@@ -0,0 +1,100 @@
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { ApiError } from "@/lib/auth/api-client";
import { errorMessage } from "@/lib/forms/error-message";
import { restrictedZone } from "./errors";
import { restrictionsAmong, type ActiveRestriction } from "./restrictions-api";
import { activeRestrictionsKey, useActiveRestrictions } from "./use-active-restrictions";
/** A failed save, worded for the place that shows it. */
export interface ExplainedError {
text: string;
/** A 409: the focus jumps to the message. */
focus: boolean;
/** A 409 "Bitte neu laden": offer to reload the colony. */
reload: boolean;
}
const ZONE_FALLBACK = "Der Stand liegt im Sperrbezirk.";
/**
* Puts the focus on the element (a message with tabIndex -1) when `active`
* turns true - after a 409 the beekeeper must hear what happened.
*/
export function useFocusWhen(active: boolean) {
const ref = useRef<HTMLParagraphElement>(null);
useEffect(() => {
if (active) ref.current?.focus();
}, [active]);
return ref;
}
export interface RestrictionGuard {
/** Active restrictions of the apiaries the action touches. */
blocking: ActiveRestriction[];
isBlocked: boolean;
/** Changes with the set of blocking restrictions; use it as a React `key`. */
resetKey: string;
/** The confirmed override reason, only while something blocks. */
overrideReason: string | null;
/** The action may go ahead: nothing blocks, or the override was confirmed. */
canProceed: boolean;
/** Id of the notice, for `aria-describedby` of the switched-off button. */
noticeId: string;
setReason: (reason: string | null) => void;
reset: () => void;
/**
* The text of a failed save: the server wording (409 RESTRICTED_ZONE,
* "Der Stand wurde gerade geändert"), otherwise the fallback. A blocked
* zone the client did not know yet is loaded again.
*/
explain: (error: unknown, fallback: string) => ExplainedError;
/** Loads the active restrictions again (a dialog opens: the list may be old). */
refresh: () => void;
/** The request fragment: the reason only when the override is really used. */
overrideFields: () => { restrictionOverrideReason?: string };
}
/**
* Whether an action that brings a colony into, out of or between apiaries is
* blocked by a restricted zone - and the state of the override. The server
* decides in the end; this hides a blocked action early.
*/
export function useRestrictionGuard(apiaryIds: readonly string[]): RestrictionGuard {
const { restrictions } = useActiveRestrictions();
const noticeId = useId();
const queryClient = useQueryClient();
const [stored, setStored] = useState<{ key: string; reason: string } | null>(null);
const blocking = restrictionsAmong(apiaryIds, restrictions);
const resetKey = blocking.map((restriction) => restriction.id).join(",");
const isBlocked = blocking.length > 0;
// Nothing blocks any more: a reason confirmed earlier belongs to a notice that is gone.
if (!isBlocked && stored !== null) setStored(null);
const overrideReason = isBlocked && stored?.key === resetKey ? stored.reason : null;
return {
blocking,
isBlocked,
resetKey,
overrideReason,
canProceed: !isBlocked || overrideReason !== null,
noticeId,
setReason: (reason) => setStored(reason === null ? null : { key: resetKey, reason }),
reset: () => setStored(null),
explain: (error, fallback) => {
const zone = restrictedZone(error);
if (zone) void queryClient.invalidateQueries({ queryKey: activeRestrictionsKey });
const conflict = error instanceof ApiError && error.status === 409;
const text = zone ? zone.message || ZONE_FALLBACK : errorMessage(error, fallback);
return { text, focus: conflict, reload: conflict && !zone && /neu laden/i.test(text) };
},
refresh: () => {
void queryClient.invalidateQueries({ queryKey: activeRestrictionsKey });
},
overrideFields: () => (overrideReason !== null ? { restrictionOverrideReason: overrideReason } : {}),
};
}
@@ -0,0 +1,9 @@
import { describe, expect, it } from "vitest";
import { NOTIFICATION_CATEGORIES, NOTIFICATION_CATEGORY_LABELS } from "./labels";
describe("notification category labels", () => {
it("names the compliance category and offers it in the category selection", () => {
expect(NOTIFICATION_CATEGORY_LABELS.COMPLIANCE).toBe("Behörden & Prüfung");
expect(NOTIFICATION_CATEGORIES).toContain("COMPLIANCE");
});
});
@@ -7,6 +7,7 @@ export const NOTIFICATION_CATEGORY_LABELS: Record<NotificationCategory, string>
WITHDRAWAL: "Wartezeit", WITHDRAWAL: "Wartezeit",
SEASON: "Saison", SEASON: "Saison",
SYSTEM: "System", SYSTEM: "System",
COMPLIANCE: "Behörden & Prüfung",
}; };
export const NOTIFICATION_CATEGORIES = Object.keys( export const NOTIFICATION_CATEGORIES = Object.keys(
@@ -6,7 +6,8 @@ export type NotificationCategory =
| "SUGGESTION" | "SUGGESTION"
| "WITHDRAWAL" | "WITHDRAWAL"
| "SEASON" | "SEASON"
| "SYSTEM"; | "SYSTEM"
| "COMPLIANCE";
export interface Notification { export interface Notification {
id: string; id: string;
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { bookingLine } from "./booking-line";
describe("bookingLine", () => {
it("multiplies the dose by the number of colonies", () => {
expect(bookingLine(30, "ML", 6)).toBe("Bucht 30 ml je Volk ab (× 6 = 180 ml).");
});
it("leaves the multiplication out for one colony", () => {
expect(bookingLine(30, "ML", 1)).toBe("Bucht 30 ml je Volk ab.");
expect(bookingLine(30, "ML", 0)).toBe("Bucht 30 ml je Volk ab.");
});
it("writes German decimals and the unit of the medicine", () => {
expect(bookingLine(2.5, "G", 3)).toBe("Bucht 2,5 g je Volk ab (× 3 = 7,5 g).");
expect(bookingLine(1, "PIECE", 2)).toBe("Bucht 1 Stück je Volk ab (× 2 = 2 Stück).");
});
it("does not show float noise", () => {
expect(bookingLine(0.1, "ML", 3)).toBe("Bucht 0,1 ml je Volk ab (× 3 = 0,3 ml).");
});
it("is null without a pack or without a dose", () => {
expect(bookingLine(30, null, 6)).toBeNull();
expect(bookingLine(null, "ML", 6)).toBeNull();
expect(bookingLine(Number.NaN, "ML", 6)).toBeNull();
expect(bookingLine(0, "ML", 6)).toBeNull();
});
});
@@ -0,0 +1,19 @@
import { formatQuantity } from "@/lib/medicines/format";
import type { MedicineUnit } from "@/lib/medicines/types";
/**
* The line above „Speichern“ that tells what the treatment takes out of the
* pack: "Bucht 30 ml je Volk ab (× 6 = 180 ml)." `unit` is `null` when no pack
* is chosen (nothing is booked then), `dose` is `null` while none is typed.
* With one colony the multiplication is left out.
*/
export function bookingLine(
dose: number | null,
unit: MedicineUnit | null,
colonyCount: number,
): string | null {
if (dose === null || unit === null || !Number.isFinite(dose) || dose <= 0) return null;
const perColony = `Bucht ${formatQuantity(dose, unit)} je Volk ab`;
if (colonyCount <= 1) return `${perColony}.`;
return `${perColony} (× ${colonyCount} = ${formatQuantity(dose * colonyCount, unit)}).`;
}
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { doseError, doseText, parsedDose } from "./dose";
describe("doseError", () => {
it("allows an empty dose and valid ones with up to three decimals", () => {
expect(doseError("")).toBeNull();
expect(doseError("2,5")).toBeNull();
expect(doseError("0,001")).toBeNull();
});
it("rejects more than three decimal places in German", () => {
expect(doseError("0,0004")).toBe("Höchstens 3 Nachkommastellen.");
expect(doseError("2,5001")).toBe("Höchstens 3 Nachkommastellen.");
});
it("rejects zero and text", () => {
expect(doseError("0")).toBe("Die Dosis muss mindestens 0,001 sein.");
expect(doseError("abc")).toMatch(/Zahl/);
});
});
describe("parsedDose / doseText", () => {
it("round-trips a German decimal", () => {
expect(parsedDose(doseText(2.5))).toBe(2.5);
expect(doseText(null)).toBe("");
});
});
@@ -0,0 +1,29 @@
import { decimalMessage, parseDecimal } from "@/lib/medicines/format";
/** The smallest dose the server accepts. */
export const MIN_DOSE = 0.001;
const MAX_DECIMALS = 3;
const DECIMALS = /[.,](\d+)$/;
/** What is wrong with the typed dose, or `null` (an empty dose is allowed: nothing is booked). */
export function doseError(text: string): string | null {
if (text.trim() === "") return null;
const dose = parseDecimal(text);
if (Number.isNaN(dose)) return decimalMessage(text, "Bitte die Dosis als Zahl angeben, z. B. 2,5.");
const decimals = DECIMALS.exec(text.trim())?.[1].length ?? 0;
if (decimals > MAX_DECIMALS) return "Höchstens 3 Nachkommastellen.";
if (!(dose >= MIN_DOSE)) return "Die Dosis muss mindestens 0,001 sein.";
return null;
}
/** The typed dose as a number, `null` when empty or not a valid number. */
export function parsedDose(text: string): number | null {
if (text.trim() === "") return null;
const dose = parseDecimal(text);
return Number.isFinite(dose) ? dose : null;
}
/** A stored dose as the user types it: "2.5" is „2,5“. */
export function doseText(dose: number | null): string {
return dose === null ? "" : String(dose).replace(".", ",");
}
@@ -0,0 +1,106 @@
import { describe, expect, it } from "vitest";
import { medicinePatch, type MedicineGroupForm } from "./edit-patch";
import type { Treatment } from "./types";
const FREE_TEXT: Treatment = {
id: "t-1",
colonyId: "c-1",
userId: "u-1",
date: "2026-08-01T00:00:00.000Z",
startDate: null,
endDate: null,
product: "Oxuvar",
activeIngredient: null,
amount: null,
method: null,
reason: null,
batchNumber: "Ch-1",
notes: null,
withdrawalPeriodDays: null,
supplier: "Imkereibedarf",
purchaseProof: null,
prescribingVet: null,
medicineId: null,
medicineLotId: null,
doseQuantity: null,
createdAt: "2026-08-01T00:00:00.000Z",
};
const FROM_PACK: Treatment = {
...FREE_TEXT,
batchNumber: null,
supplier: null,
medicineId: "m-1",
medicineLotId: "l-1",
doseQuantity: 30,
};
function formOf(treatment: Treatment, overrides: Partial<MedicineGroupForm> = {}): MedicineGroupForm {
return {
product: treatment.product,
medicineId: treatment.medicineId ?? "",
medicineLotId: treatment.medicineLotId ?? "",
doseQuantity: treatment.doseQuantity === null ? "" : String(treatment.doseQuantity),
batchNumber: treatment.batchNumber ?? "",
supplier: treatment.supplier ?? "",
purchaseProof: treatment.purchaseProof ?? "",
prescribingVet: treatment.prescribingVet ?? "",
...overrides,
};
}
describe("medicinePatch", () => {
it("is empty while nothing of the group changed", () => {
expect(medicinePatch(FREE_TEXT, formOf(FREE_TEXT))).toEqual({});
expect(medicinePatch(FROM_PACK, formOf(FROM_PACK))).toEqual({});
});
it("clears the lot-sourced texts with null once a pack is in play", () => {
expect(medicinePatch(FROM_PACK, formOf(FROM_PACK, { doseQuantity: "2,5" }))).toEqual({
medicineId: "m-1",
medicineLotId: "l-1",
doseQuantity: 2.5,
batchNumber: null,
supplier: null,
purchaseProof: null,
prescribingVet: null,
});
});
it("sends the typed texts and no pack for a catalogue medicine without a pack", () => {
expect(
medicinePatch(FROM_PACK, formOf(FROM_PACK, { medicineLotId: "", supplier: "Apotheke" })),
).toEqual({
medicineId: "m-1",
medicineLotId: null,
doseQuantity: 30,
batchNumber: null,
supplier: "Apotheke",
purchaseProof: null,
prescribingVet: null,
});
});
it("sends an emptied dose as null", () => {
expect(medicinePatch(FROM_PACK, formOf(FROM_PACK, { doseQuantity: "" })).doseQuantity).toBeNull();
});
it("clears medicine, pack and dose that were stored when a free-text medicine is chosen", () => {
expect(
medicinePatch(
FROM_PACK,
formOf(FROM_PACK, { medicineId: "", medicineLotId: "", product: " Eigenes " }),
),
).toMatchObject({ product: "Eigenes", medicineId: null, medicineLotId: null, doseQuantity: null });
});
it("does not null what a free-text treatment never had", () => {
const patch = medicinePatch(FREE_TEXT, formOf(FREE_TEXT, { supplier: "" }));
expect(patch).toEqual({
product: "Oxuvar",
batchNumber: "Ch-1",
supplier: null,
purchaseProof: null,
prescribingVet: null,
});
});
});
@@ -0,0 +1,74 @@
import { emptyToNull } from "@/lib/forms/empty-to-null";
import { parsedDose } from "./dose";
import type { Treatment, UpdateTreatmentInput } from "./types";
/** The part of the edit form that decides which medicine, pack and texts a treatment has. */
export interface MedicineGroupForm {
product: string;
medicineId: string;
medicineLotId: string;
doseQuantity: string;
batchNumber: string;
supplier: string;
purchaseProof: string;
prescribingVet: string;
}
function sameText(typed: string, stored: string | null): boolean {
return emptyToNull(typed) === stored;
}
function isUnchanged(treatment: Treatment, form: MedicineGroupForm): boolean {
return (
form.product.trim() === treatment.product &&
form.medicineId === (treatment.medicineId ?? "") &&
form.medicineLotId === (treatment.medicineLotId ?? "") &&
parsedDose(form.doseQuantity) === treatment.doseQuantity &&
sameText(form.batchNumber, treatment.batchNumber) &&
sameText(form.supplier, treatment.supplier) &&
sameText(form.purchaseProof, treatment.purchaseProof) &&
sameText(form.prescribingVet, treatment.prescribingVet)
);
}
/** What the pack supplies is cleared on the treatment (the server answers 400 for text next to a pack). */
function textFields(form: MedicineGroupForm) {
if (form.medicineLotId) {
return { batchNumber: null, supplier: null, purchaseProof: null, prescribingVet: null };
}
return {
batchNumber: emptyToNull(form.batchNumber),
supplier: emptyToNull(form.supplier),
purchaseProof: emptyToNull(form.purchaseProof),
prescribingVet: emptyToNull(form.prescribingVet),
};
}
/**
* The medicine part of an update. Left out completely while nothing of it
* changed - the server checks catalogue and pack only when the patch touches
* them, so a note on an old treatment is saved even if its pack was changed
* since. Once something changed the whole group goes out, with `null` for
* everything the user cleared (a stored value must stay removable).
*/
export function medicinePatch(
treatment: Treatment,
form: MedicineGroupForm,
): Partial<UpdateTreatmentInput> {
if (isUnchanged(treatment, form)) return {};
const dose = parsedDose(form.doseQuantity);
const medicine: Partial<UpdateTreatmentInput> = form.medicineId
? {
// The trade name follows the catalogue.
medicineId: form.medicineId,
medicineLotId: form.medicineLotId || null,
doseQuantity: dose,
}
: {
product: form.product.trim(),
...(treatment.medicineId ? { medicineId: null } : {}),
...(treatment.medicineLotId ? { medicineLotId: null } : {}),
...(treatment.medicineId && treatment.doseQuantity !== null ? { doseQuantity: null } : {}),
};
return { ...medicine, ...textFields(form) };
}
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import { lot } from "@/lib/medicines/fixtures.spec-helper";
import { lotLabel, lotSourceLine, lotWarnings } from "./lot-text";
const ML_LOT = lot({
unit: "ML",
batchNumber: "4711",
remaining: 180,
expiresOn: "2027-03-31",
supplier: "Tierarzt Muster",
purchaseProof: "Rechnung 4711",
prescribingVet: null,
});
describe("lotLabel", () => {
it("shows batch, rest and expiry", () => {
expect(lotLabel(ML_LOT)).toBe("Ch.-B. 4711 · Rest 180 ml · verw. bis 03/2027");
});
it("says so when there is no batch number and leaves out a missing expiry", () => {
expect(lotLabel({ ...ML_LOT, batchNumber: null, expiresOn: null })).toBe(
"Ohne Ch.-B. · Rest 180 ml",
);
});
});
describe("lotSourceLine", () => {
it("lists only what the pack holds", () => {
expect(lotSourceLine(ML_LOT)).toBe(
"aus Packung: Ch.-B. 4711 · Lieferant Tierarzt Muster · Beleg Rechnung 4711",
);
});
it("still says where the data comes from when the pack holds nothing", () => {
expect(
lotSourceLine({ ...ML_LOT, batchNumber: null, supplier: null, purchaseProof: null }),
).toBe("aus Packung");
});
});
describe("lotWarnings", () => {
it("warns about an expired pack, judged on the treatment day", () => {
expect(lotWarnings(ML_LOT, 30, 1, "2027-03-31")).toEqual([]);
expect(lotWarnings(ML_LOT, 30, 1, "2027-04-01")).toEqual([
"Die Packung ist abgelaufen (verw. bis 03/2027).",
]);
});
it("warns when the rest does not cover all colonies", () => {
expect(lotWarnings(ML_LOT, 30, 7, "2026-08-01")).toEqual([
"Die Packung reicht nicht: Rest 180 ml, gebraucht 210 ml.",
]);
expect(lotWarnings(ML_LOT, 30, 6, "2026-08-01")).toEqual([]);
});
it("does not warn about float noise", () => {
expect(lotWarnings({ ...ML_LOT, remaining: 0.3 }, 0.1, 3, "2026-08-01")).toEqual([]);
});
it("warns about an empty pack when no dose is typed", () => {
expect(lotWarnings({ ...ML_LOT, remaining: 0 }, null, 1, "2026-08-01")).toEqual([
"Die Packung ist leer.",
]);
});
});
@@ -0,0 +1,59 @@
import { formatQuantity, roundToThousandths } from "@/lib/medicines/format";
import type { LotView } from "@/lib/medicines/types";
const SEPARATOR = " · ";
/** "2027-03-31" as "03/2027", read as written. */
function monthYear(day: string): string {
const [year, month] = day.split("-");
return `${month}/${year}`;
}
/** "Ch.-B. 4711 · Rest 180 ml · verw. bis 03/2027" */
export function lotLabel(lot: LotView): string {
const parts = [
lot.batchNumber ? `Ch.-B. ${lot.batchNumber}` : "Ohne Ch.-B.",
`Rest ${formatQuantity(lot.remaining, lot.unit)}`,
];
if (lot.expiresOn) parts.push(`verw. bis ${monthYear(lot.expiresOn)}`);
return parts.join(SEPARATOR);
}
/** What the pack supplies instead of the typed fields, e.g. "aus Packung: Ch.-B. 4711 · Lieferant X". */
export function lotSourceLine(lot: LotView): string {
const parts = [
lot.batchNumber ? `Ch.-B. ${lot.batchNumber}` : null,
lot.supplier ? `Lieferant ${lot.supplier}` : null,
lot.purchaseProof ? `Beleg ${lot.purchaseProof}` : null,
lot.prescribingVet ? `Tierarzt ${lot.prescribingVet}` : null,
].filter((part): part is string => part !== null);
return parts.length > 0 ? `aus Packung: ${parts.join(SEPARATOR)}` : "aus Packung";
}
/**
* What speaks against taking from this pack: expired on the treatment day, or
* too little left for the dose of all colonies. Only warnings - the server
* books anyway and the readiness check reports a negative rest.
*/
export function lotWarnings(
lot: LotView,
dose: number | null,
colonyCount: number,
treatmentDay: string,
): string[] {
const warnings: string[] = [];
if (lot.expiresOn && treatmentDay && lot.expiresOn < treatmentDay) {
warnings.push(`Die Packung ist abgelaufen (verw. bis ${monthYear(lot.expiresOn)}).`);
}
if (dose !== null && Number.isFinite(dose) && dose > 0) {
const needed = roundToThousandths(dose * Math.max(colonyCount, 1));
if (needed > lot.remaining) {
warnings.push(
`Die Packung reicht nicht: Rest ${formatQuantity(lot.remaining, lot.unit)}, gebraucht ${formatQuantity(needed, lot.unit)}.`,
);
}
} else if (lot.remaining <= 0) {
warnings.push("Die Packung ist leer.");
}
return warnings;
}
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { temperatureHint } from "./temperature-hint";
const WINDOW = { min: 12, max: 25 };
describe("temperatureHint", () => {
it("names today and the window of the catalogue", () => {
expect(temperatureHint({ min: 14, max: 22 }, WINDOW)).toEqual({
text: "Heute 14–22 °C. Laut Katalog 12–25 °C.",
warn: false,
});
});
it("warns when today is colder or warmer than the window", () => {
expect(temperatureHint({ min: 9, max: 17 }, WINDOW)).toEqual({
text: "Heute 9–17 °C. Laut Katalog 12–25 °C.",
warn: true,
});
expect(temperatureHint({ min: 20, max: 31 }, WINDOW)?.warn).toBe(true);
});
it("counts the limits themselves as inside", () => {
expect(temperatureHint({ min: 12, max: 25 }, WINDOW)?.warn).toBe(false);
});
it("copes with a window that has only one limit", () => {
expect(temperatureHint({ min: 14, max: 22 }, { min: 12, max: null })).toEqual({
text: "Heute 14–22 °C. Laut Katalog ab 12 °C.",
warn: false,
});
expect(temperatureHint({ min: 14, max: 30 }, { min: null, max: 25 })).toEqual({
text: "Heute 14–30 °C. Laut Katalog bis 25 °C.",
warn: true,
});
});
it("writes German decimals", () => {
expect(temperatureHint({ min: 9.5, max: 17 }, WINDOW)?.text).toBe(
"Heute 9,5–17 °C. Laut Katalog 12–25 °C.",
);
});
it("is null when either side knows no temperature", () => {
expect(temperatureHint({ min: null, max: null }, WINDOW)).toBeNull();
expect(temperatureHint({ min: 9, max: 17 }, { min: null, max: null })).toBeNull();
});
});
@@ -0,0 +1,46 @@
export interface TemperatureRange {
min: number | null;
max: number | null;
}
export interface TemperatureHint {
text: string;
/** Today lies (partly) outside the window of the catalogue. */
warn: boolean;
}
const EN_DASH = "–";
function degrees(value: number): string {
return value.toLocaleString("de-DE", { maximumFractionDigits: 1 });
}
function rangeText({ min, max }: TemperatureRange): string | null {
if (min !== null && max !== null) return `${degrees(min)}${EN_DASH}${degrees(max)} °C`;
if (min !== null) return `ab ${degrees(min)} °C`;
if (max !== null) return `bis ${degrees(max)} °C`;
return null;
}
function isOutside(today: TemperatureRange, window: TemperatureRange): boolean {
const tooCold = window.min !== null && today.min !== null && today.min < window.min;
const tooWarm = window.max !== null && today.max !== null && today.max > window.max;
return tooCold || tooWarm;
}
/**
* "Heute 9–17 °C. Laut Katalog 12–25 °C." - nothing when either side has no
* temperature at all. The limits themselves are still inside the window.
*/
export function temperatureHint(
today: TemperatureRange,
window: TemperatureRange,
): TemperatureHint | null {
const todayText = rangeText(today);
const windowText = rangeText(window);
if (todayText === null || windowText === null) return null;
return {
text: `Heute ${todayText}. Laut Katalog ${windowText}.`,
warn: isOutside(today, window),
};
}
+16 -1
View File
@@ -16,6 +16,12 @@ export interface Treatment {
supplier: string | null; supplier: string | null;
purchaseProof: string | null; purchaseProof: string | null;
prescribingVet: string | null; prescribingVet: string | null;
/** Catalogue entry (veterinary medicine) the treatment was taken from. */
medicineId: string | null;
/** Pack the dose was booked from; supplier, proof, batch and vet then come from it. */
medicineLotId: string | null;
/** Dose per colony in the unit of the medicine. */
doseQuantity: number | null;
createdAt: string; createdAt: string;
} }
@@ -25,7 +31,12 @@ export interface CreateTreatmentInput {
date: string; date: string;
startDate?: string | null; startDate?: string | null;
endDate?: string | null; endDate?: string | null;
product: string; /** Left out when a catalogue medicine is chosen: the server takes its name. */
product?: string;
medicineId?: string | null;
/** With a pack, supplier / purchaseProof / batchNumber / prescribingVet must be left out (400 otherwise). */
medicineLotId?: string | null;
doseQuantity?: number | null;
activeIngredient?: string | null; activeIngredient?: string | null;
amount?: string | null; amount?: string | null;
method?: string | null; method?: string | null;
@@ -46,6 +57,10 @@ export interface UpdateTreatmentInput {
startDate?: string | null; startDate?: string | null;
endDate?: string | null; endDate?: string | null;
product?: string; product?: string;
/** `null` clears; with a pack set, `medicineId` must stay set. */
medicineId?: string | null;
medicineLotId?: string | null;
doseQuantity?: number | null;
activeIngredient?: string | null; activeIngredient?: string | null;
amount?: string | null; amount?: string | null;
method?: string | null; method?: string | null;
@@ -0,0 +1,97 @@
"use client";
import { Dispatch, SetStateAction, useState } from "react";
import type { MedicineView } from "@/lib/medicines/types";
import { doseText } from "./dose";
/** The form fields a catalogue medicine can fill in (as typed text). */
export const CATALOGUE_FIELDS = [
"activeIngredient",
"withdrawalPeriodDays",
"method",
"doseQuantity",
] as const;
export type CatalogueField = (typeof CATALOGUE_FIELDS)[number];
/** Fields filled from the catalogue and not touched since: they carry the note „aus Katalog“. */
export type CatalogueFilled = Partial<Record<CatalogueField, true>>;
type CatalogueValues = Record<CatalogueField, string>;
function isCatalogueField(key: string): key is CatalogueField {
return (CATALOGUE_FIELDS as readonly string[]).includes(key);
}
/** What the catalogue knows of the medicine; unknown values (null) are left out. */
export function catalogueValues(medicine: MedicineView): Partial<CatalogueValues> {
const values: Partial<CatalogueValues> = {};
if (medicine.activeIngredient !== null) values.activeIngredient = medicine.activeIngredient;
if (medicine.withdrawalPeriodDays !== null) {
values.withdrawalPeriodDays = String(medicine.withdrawalPeriodDays);
}
if (medicine.applicationMethod !== null) values.method = medicine.applicationMethod;
if (medicine.dosePerColony !== null) values.doseQuantity = doseText(medicine.dosePerColony);
return values;
}
interface CataloguePrefill {
filled: CatalogueFilled;
/**
* Fills the fields the user has not touched from the medicine (`null` =
* „Anderes Mittel“: only takes back what an earlier medicine filled in).
* `markEdited` tells the last-treatment prefill to leave these fields alone,
* so the catalogue wins.
*/
apply: (medicine: MedicineView | null) => void;
/** Call for every edit: the field belongs to the user and is never refilled. */
edited: (key: string) => void;
/** After a draft replaced the values: whatever it holds belongs to the user. */
resetFrom: (values: CatalogueValues) => void;
}
/** Prefills a treatment form from the chosen catalogue medicine. */
export function useCataloguePrefill<T extends CatalogueValues>(
setValues: Dispatch<SetStateAction<T>>,
markEdited: (key: string) => void,
): CataloguePrefill {
const [filled, setFilled] = useState<CatalogueFilled>({});
const [touched, setTouched] = useState<readonly CatalogueField[]>([]);
return {
filled,
apply: (medicine) => {
const next = medicine ? catalogueValues(medicine) : {};
const nextFilled: CatalogueFilled = {};
setValues((current) => {
const updated = { ...current };
for (const field of CATALOGUE_FIELDS) {
if (touched.includes(field)) continue;
if (filled[field]) updated[field] = "" as T[CatalogueField];
const value = next[field];
if (value !== undefined) updated[field] = value as T[CatalogueField];
}
return updated;
});
for (const field of CATALOGUE_FIELDS) {
if (touched.includes(field) || next[field] === undefined) continue;
nextFilled[field] = true;
markEdited(field);
}
setFilled(nextFilled);
},
edited: (key) => {
if (!isCatalogueField(key)) return;
setTouched((current) => (current.includes(key) ? current : [...current, key]));
setFilled((current) => {
const rest = { ...current };
delete rest[key];
return rest;
});
},
resetFrom: (values) => {
setFilled({});
setTouched(CATALOGUE_FIELDS.filter((field) => values[field].trim() !== ""));
},
};
}
@@ -0,0 +1,126 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { fetchMedicines, fetchSelectableLots } from "@/lib/medicines/api";
import type { LotView, MedicineView } from "@/lib/medicines/types";
interface QueryStatus {
isLoading: boolean;
isError: boolean;
isRetrying: boolean;
retry: () => void;
}
export interface MedicineChoice {
/** The user may read the catalogue; without the right nothing is asked for. */
canRead: boolean;
/** The medicines on offer: the active ones, plus the one a treatment already has. */
medicines: readonly MedicineView[] | undefined;
/** Every known medicine, so a chosen one is always named. */
allMedicines: readonly MedicineView[] | undefined;
medicinesStatus: QueryStatus;
medicine: MedicineView | undefined;
/** The selectable lots of the chosen medicine, oldest expiry first. */
lots: readonly LotView[] | undefined;
lotsStatus: QueryStatus;
lot: LotView | undefined;
/** The lot the form chose by itself and nobody has changed since. */
preselectedLotId: string | null;
/** The user made the lot choice (including „Ohne Packung“): never choose again. */
decideLot: () => void;
/** A new medicine was chosen: the oldest lot may be preselected again. */
undecideLot: () => void;
}
interface Options {
canRead: boolean;
medicineId: string;
medicineLotId: string;
/** The pack of a treatment that is being edited; stays on the list when empty. */
includeLotId?: string;
/** The medicine of a treatment that is being edited; offered even when switched off. */
keepMedicineId?: string;
/** A form that edits an existing treatment never chooses a pack by itself. */
startDecided?: boolean;
onPreselect: (lotId: string) => void;
}
function status(query: {
isLoading: boolean;
isError: boolean;
isFetching: boolean;
refetch: () => unknown;
}): QueryStatus {
return {
isLoading: query.isLoading,
isError: query.isError,
isRetrying: query.isFetching,
retry: () => void query.refetch(),
};
}
/**
* The catalogue medicines and the packs of the chosen one for a treatment
* form. A missing right is no error: nothing is asked and only „Anderes
* Mittel“ is offered. The oldest pack is chosen once per medicine until the
* user decides themselves.
*/
export function useMedicineChoice({
canRead,
medicineId,
medicineLotId,
includeLotId,
keepMedicineId,
startDecided = false,
onPreselect,
}: Options): MedicineChoice {
const medicinesQuery = useQuery({
queryKey: ["medicines"],
queryFn: () => fetchMedicines(),
enabled: canRead,
});
const lotsQuery = useQuery({
queryKey: ["medicine-lots", "selectable", medicineId, includeLotId ?? null],
queryFn: () => fetchSelectableLots(medicineId, includeLotId),
enabled: canRead && medicineId !== "",
});
const [decided, setDecided] = useState(startDecided);
const [preselectedLotId, setPreselectedLotId] = useState<string | null>(null);
const [handled, setHandled] = useState<string | null>(null);
const lots = medicineId ? lotsQuery.data : undefined;
const oldestLotId = lots?.[0]?.id;
const preselectKey = oldestLotId ? `${medicineId}:${oldestLotId}` : null;
// Chosen while rendering (like the prefill hooks): the form never shows a
// frame with a medicine and a pack that is still to be picked.
if (!decided && !medicineLotId && oldestLotId && preselectKey !== handled) {
setHandled(preselectKey);
setPreselectedLotId(oldestLotId);
onPreselect(oldestLotId);
}
const allMedicines = canRead ? medicinesQuery.data : undefined;
const medicines = allMedicines?.filter(
(candidate) => candidate.active || candidate.id === keepMedicineId,
);
return {
canRead,
medicines,
allMedicines,
medicinesStatus: status(medicinesQuery),
medicine: allMedicines?.find((candidate) => candidate.id === medicineId),
lots,
lotsStatus: status(lotsQuery),
lot: lots?.find((candidate) => candidate.id === medicineLotId),
preselectedLotId: medicineLotId && medicineLotId === preselectedLotId ? preselectedLotId : null,
decideLot: () => setDecided(true),
undecideLot: () => {
setDecided(false);
setHandled(null);
setPreselectedLotId(null);
},
};
}
+12
View File
@@ -320,6 +320,18 @@ Plan: `docs/superpowers/plans/2026-10-13-t4-logik-ketten.md`, Annahmen 31–58 i
- [ ] Folgepunkt: `packagingSize` im Backend als `@IsInt @Min(1) @Max(10000)` prüfen, nach Datenprüfung in Produktion (braucht Freigabe) - [ ] Folgepunkt: `packagingSize` im Backend als `@IsInt @Min(1) @Max(10000)` prüfen, nach Datenprüfung in Produktion (braucht Freigabe)
- [ ] Ausrollen in den Cluster – gebaut und getestet, noch nicht ausgerollt - [ ] Ausrollen in den Cluster – gebaut und getestet, noch nicht ausgerollt
## T6. Compliance (Tierarzneimittel, Etikett, Behörden, Prüfbereitschaft)
Plan: `docs/superpowers/plans/2026-10-14-t6-compliance.md`, Annahmen ab 59 in ARCHITECTURE.md (zuletzt 94), Beschreibung in Abschnitt 12.
- [x] T6a (Tasks 1–13, Backend): Schema und Rechte, Tierarzneimittel mit Packungen und Bewegungsbuch, Dokumente, Etikett-Pflichtangaben mit Druck-Sperre und Probedruck, Sperrbezirk, Behördenpaket (Anzeigen, PDFs), Hinweise und Tagesauswertung, Prüfbereitschaft
- [x] T6b (Tasks 14–19, Frontend): Prüfbereitschaft, Tierarzneimittel, Behandlungsmaske mit Mittel/Packung/Dosis, Etikett-Prüfung, Anzeigen und Einstellungen, Sperrbezirk mit Wächtern
- [ ] Browserprüfung aller T6-Seiten (Desktop und 390 px, Fehler- und Leerzustände, Tastatur, `prefers-reduced-motion`)
- [ ] Folgepunkt: `packagingSize` im Backend als `@IsInt` prüfen, nach Datenprüfung in Produktion (braucht Freigabe)
- [ ] Frage an den Inhaber: „Wer darf Etiketten drucken?“ (heute nur ADMIN, Annahme 77)
- [ ] Rechtswerte in `ComplianceSettings` vom Inhaber prüfen lassen (Fristen, Paragraphen, Ziffernhöhen, x-Höhe, Landesrecht Bayern; gegen gesetze-im-internet.de und EUR-Lex)
- [ ] Ausrollen in den Cluster – gebaut und getestet, noch nicht ausgerollt (Runbook; Secrets über `deploy/k8s/scripts/ensure-secrets.sh`)
## Später (KANN) – Abschnitt 26, Liste 3 ## Später (KANN) – Abschnitt 26, Liste 3
Software-seitig umsetzbare Punkte werden gebaut. Punkte, die dedizierte native Build-Pipelines, echte ML-Modelle oder physische Hardware voraussetzen, sind als "nicht umsetzbar in diesem Kontext" markiert statt mit einer Alibi-Umsetzung – Begründung jeweils dahinter. Software-seitig umsetzbare Punkte werden gebaut. Punkte, die dedizierte native Build-Pipelines, echte ML-Modelle oder physische Hardware voraussetzen, sind als "nicht umsetzbar in diesem Kontext" markiert statt mit einer Alibi-Umsetzung – Begründung jeweils dahinter.