From d30bb6d23582126176500a99831910cecfeac34c Mon Sep 17 00:00:00 2001
From: Florian Wilde
Date: Sat, 24 Oct 2026 01:14:04 +0200
Subject: [PATCH] feat(laeufe): Vier-Augen-Freigabe (#040)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Verlangt eine Vorlagen-Version eine Freigabe, laesst sich ein Lauf erst
abschliessen, wenn ein Zweiter ihn freigegeben hat. Das braucht zweierlei, und
beides zusammen: das neue Recht `freigeben` (ADMIN und REDAKTEUR, nicht
TECHNIKER) und dass man nicht an dem Lauf gearbeitet hat.
Als gearbeitet zaehlt: angelegt, einen Schritt gesetzt oder abgeschlossen. Eine
Zuweisung allein nicht — waere die Regel hier strenger, blockierte sie kleine
Teams ohne Gewinn.
Auch ein Administrator kann sich nicht selbst freigeben, und das ist der ganze
Sinn. Die Pruefung sitzt in `hatDaranGearbeitet` an einer Stelle, wie
`laufMussVeraenderbarSein`; die wichtigste Haelfte — der Ersteller — haelt
zusaetzlich ein CHECK in der Datenbank.
Zurueckweisen verlangt eine Begruendung, setzt den Lauf auf IN_ARBEIT und nimmt
eine erteilte Freigabe zurueck. Kein fuenfter LaufStatus: ein neuer Enum-Wert
haette jeden Codepfad betroffen, der heute vier Faelle kennt.
Sobald jemand abzuschliessen versucht und die Freigabe fehlt, bekommen die
Freigabeberechtigten eine Mail — ein anderer Empfaengerkreis als bei jeder
anderen Meldung, und ohne die, die gearbeitet haben. Die Aufforderung entsteht
ausserhalb der Transaktion: innerhalb wuerde sie mit dem Fehler zurueckgerollt,
und dann wartet ein Lauf auf etwas, von dem niemand weiss. Ein partieller
Unique-Index haelt sie bei mehreren Versuchen auf einmal.
Die Pflicht haengt an der Version, nicht an der Vorlage: eine veroeffentlichte
Version ist unveraenderlich, ein laufender Lauf behaelt also seine Regel. Beim
Bearbeiten und Duplizieren wandert sie mit — das war die unmittelbare Lehre aus
#039, wo die tiefe Kopie zwei Felder stillschweigend verworfen hatte.
1526 Vitest-Tests (vorher 1496), 115 E2E-Ablaeufe gruen. Klickprobe am gebauten
Stand mit elf Pruefungen; drei rote Punkte darin waren meine eigenen
Testfehler, einer davon lehrreich: ein reload() direkt nach einer Server Action
trifft den Router-Cache von Next und zeigt kurz den alten Stand.
Closes #40
Co-Authored-By: Claude Opus 5 (1M context)
---
CHANGELOG.md | 10 +
CLAUDE.md | 9 +-
docs/anleitung/02-laeufe-abarbeiten.md | 52 +++
docs/architektur.md | 34 ++
docs/devlog.md | 60 ++-
docs/issues/040-vier-augen-freigabe.md | 107 +++--
docs/issues/README.md | 4 +-
.../20261024090000_freigabe/migration.sql | 97 +++++
prisma/schema.prisma | 26 ++
src/app/(app)/admin/zustellungen/page.tsx | 1 +
src/app/(app)/laeufe/[id]/page.tsx | 31 ++
src/app/(app)/vorlagen/[id]/page.tsx | 13 +-
.../vorlagen/version/[versionId]/page.tsx | 1 +
src/components/laeufe/lauf-aktionen.tsx | 105 ++++-
src/components/vorlagen/editor.tsx | 29 ++
src/lib/meldungen.ts | 21 +-
src/server/actions/laeufe.ts | 53 +++
src/server/actions/vorlagen.ts | 10 +-
src/server/auth/rollen.ts | 13 +
src/server/queries/laeufe.ts | 17 +-
src/server/queries/vorlagen.ts | 7 +
src/server/services/benachrichtigungen.ts | 38 +-
src/server/services/laeufe.ts | 236 ++++++++++-
src/server/services/vorlagen.ts | 32 +-
tests/datenbankregeln.test.ts | 15 +
tests/freigabe.test.ts | 384 ++++++++++++++++++
tests/rollen.test.ts | 12 +-
27 files changed, 1373 insertions(+), 44 deletions(-)
create mode 100644 prisma/migrations/20261024090000_freigabe/migration.sql
create mode 100644 tests/freigabe.test.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 64316ac..c5d8bf9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,16 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Hinzugefügt
+- **Vier-Augen-Freigabe.** Verlangt eine Vorlagen-Version sie, lässt sich ein Lauf erst abschließen,
+ wenn ein **Zweiter** ihn freigegeben hat — jemand mit dem neuen Recht `freigeben` (`ADMIN` und
+ `REDAKTEUR`, **nicht** `TECHNIKER`), der nicht an dem Lauf gearbeitet hat. Als gearbeitet zählt:
+ angelegt, einen Schritt gesetzt oder abgeschlossen; eine Zuweisung allein nicht.
+ **Auch ein Administrator kann sich nicht selbst freigeben** — das ist der ganze Sinn, und dieselbe
+ Regel hält ein CHECK in der Datenbank. Zurückweisen verlangt eine Begründung, setzt den Lauf
+ zurück auf *in Arbeit* und nimmt eine erteilte Freigabe zurück.
+ Sobald jemand abzuschließen versucht und die Freigabe fehlt, bekommen die Freigabeberechtigten
+ eine Mail — einmal, auch bei mehreren Versuchen. Das Häkchen steht im Editor beim Veröffentlichen
+ und wandert beim Bearbeiten in den neuen Entwurf mit (#040)
- **Bedingte Abschnitte und Schritte.** Ein Abschnitt oder ein einzelner Schritt erscheint nur,
wenn seine Bedingung zutrifft — abhängig von einer **Variable** des Laufs oder vom **Zustand
eines anderen Schritts**. Der echte Fall steht im Seed: „Mitarbeiter-Onboarding“ trägt beide
diff --git a/CLAUDE.md b/CLAUDE.md
index d326850..1fd0a7b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -49,7 +49,7 @@ Enums: `Rolle {ADMIN REDAKTEUR TECHNIKER LESER MITARBEITER}` · `Betriebsmodus {
`VersionStatus {ENTWURF VEROEFFENTLICHT}` · `Herkunft {ABGLEICH MANUELL}` ·
`QuellenArt {LDAP GRAPH SCIM}` ·
`Zustellweg {MAIL WEBHOOK}` · `Zustellzustand {OFFEN GESENDET FEHLGESCHLAGEN AUFGEGEBEN}` ·
-`Meldeanlass {LAUF_ZUGEWIESEN LAUF_UEBERFAELLIG LAUF_ABGESCHLOSSEN ABGLEICH_FEHLGESCHLAGEN}`.
+`Meldeanlass {LAUF_ZUGEWIESEN LAUF_UEBERFAELLIG LAUF_ABGESCHLOSSEN LAUF_FREIGABE_OFFEN ABGLEICH_FEHLGESCHLAGEN}`.
`SEED_MODE` akzeptiert `DIENSTLEISTUNG` und `SERVICE` als Alias.
@@ -77,6 +77,13 @@ Dreizeiler: Recht prüfen, Zod-Schema anwenden, Service rufen.
Änderung an Kopfdaten, `Schrittergebnis` und `Kommentar` gerufen. Zurückholen nur `ADMIN`.
- **Abschluss:** nur wenn alle Pflichtschritte `ERLEDIGT` oder `NICHT_ZUTREFFEND` sind.
`NICHT_ZUTREFFEND` und `FEHLGESCHLAGEN` verlangen eine Notiz.
+- **Vier-Augen-Freigabe:** Verlangt eine `VorlagenVersion` sie (`freigabePflicht`), lässt sich ein
+ Lauf erst nach der Freigabe abschließen (#040). **Wer daran gearbeitet hat, gibt nicht frei** —
+ angelegt, einen Schritt gesetzt oder abgeschlossen; eine Zuweisung allein zählt nicht. Das prüft
+ `hatDaranGearbeitet` an genau einer Stelle, wie `laufMussVeraenderbarSein`, und ein CHECK hält die
+ wichtigste Hälfte (der Ersteller) auch gegen Schreiben an der Dienstschicht vorbei. Das Recht
+ `freigeben` hat **nicht** `TECHNIKER`: sonst hängt die Regel daran, dass zufällig ein zweiter
+ greifbar ist. Die Pflicht wandert bei „Bearbeiten“ und „Duplizieren“ in die Kopie mit.
- **Mandant:** Jede kundenbezogene Zeile trägt `kundeId`. `kundeErmitteln()` liefert im Modus
`INTERN` immer die eigene Organisation und **verwirft** eine mitgeschickte `kundeId`.
- **Rechte:** nur serverseitig, aus der `RECHTE`-Tabelle über `darf()` / `rechtErforderlich()`.
diff --git a/docs/anleitung/02-laeufe-abarbeiten.md b/docs/anleitung/02-laeufe-abarbeiten.md
index 95a5a70..ab5740f 100644
--- a/docs/anleitung/02-laeufe-abarbeiten.md
+++ b/docs/anleitung/02-laeufe-abarbeiten.md
@@ -242,6 +242,58 @@ Knopf gesperrt und darunter steht, was fehlt.
Wird danach eine Änderung nachgetragen, die einen Pflichtschritt wieder offen lässt, nimmt sich der
Abschluss selbst zurück. Damit lügt „abgeschlossen“ nie.
+## Vier-Augen-Freigabe
+
+Manche Arbeiten soll nicht derselbe abschließen, der sie gemacht hat: ein Firewall-Rollout, ein
+Restore-Test, das Löschen eines Kontos. Verlangt die Vorlage eine Freigabe, steht das oben am Lauf —
+und **Lauf abschließen** wartet, bis jemand sie erteilt hat.
+
+### Wer freigeben darf
+
+Es braucht zweierlei, und beides zusammen:
+
+1. das Recht **freigeben** — das haben `ADMIN` und `REDAKTEUR`, **nicht** `TECHNIKER`;
+2. und man darf **nicht an dem Lauf gearbeitet** haben.
+
+Als „gearbeitet“ zählt: ihn angelegt, einen Schritt gesetzt oder ihn abgeschlossen zu haben. Nur
+**zugewiesen** zu sein zählt nicht — wer zugewiesen wurde und nichts getan hat, darf freigeben.
+
+**Auch ein Administrator kann sich nicht selbst freigeben.** Das ist der ganze Sinn, und es ist eine
+bewusste Härte: wer allein im Haus ist, kommt damit nicht weiter und braucht einen Zweiten mit dem
+Recht. Dieselbe Regel hält auch die Datenbank fest, nicht nur die Anwendung.
+
+### Freigeben oder zurückweisen
+
+Oben am Lauf steht eine Karte mit einem Feld für die **Begründung** und zwei Knöpfen:
+
+- **Freigeben** — die Begründung ist freiwillig. Danach lässt sich der Lauf abschließen.
+- **Zurückweisen** — die Begründung ist **Pflicht**. Der Lauf geht zurück auf *in Arbeit*, ein
+ bereits erteilter Abschluss wird aufgehoben, und der Bearbeiter sieht die Begründung an derselben
+ Stelle. Ohne Begründung steht er vor derselben Frage wie vorher.
+
+Eine Zurückweisung nimmt auch eine **erteilte** Freigabe zurück. Damit lässt sich ein Lauf, der
+schon abgeschlossen war, in die Arbeit zurückholen — ohne `archivVerwalten`, solange er nicht
+archiviert ist.
+
+### Wer erfährt davon
+
+Sobald jemand den Lauf abzuschließen versucht und die Freigabe fehlt, bekommen die
+**Freigabeberechtigten** eine Mail — nicht der Zuständige, wie bei allen anderen Meldungen. Wer an
+dem Lauf gearbeitet hat, bekommt sie nicht: er dürfte ohnehin nicht freigeben.
+
+Das geht **einmal** hinaus, auch wenn jemand es mehrmals versucht. Nach einer Zurückweisung wird
+nicht erneut gemahnt — der Zurückweisende weiß, dass er wartet.
+
+### Einschalten
+
+Das Häkchen steht im Editor beim **Veröffentlichen**: „Läufe dieser Version brauchen eine Freigabe
+durch einen Zweiten“. Es gehört zur **Version**, nicht zur Vorlage — ein laufender Lauf kann seine
+Regel also nicht unter den Füßen verlieren, und in der Versionsliste steht an jeder Fassung, ob sie
+eine Freigabe verlangt.
+
+Beim Bearbeiten wandert das Häkchen in den neuen Entwurf mit. Eine Vorlage, die eine Freigabe
+verlangt, verliert sie also nicht durch eine Textkorrektur.
+
## Archivieren — das ist endgültig
**archivieren** fragt nach: „Nach dem Archivieren ändert sich an diesem Lauf nichts mehr — kein
diff --git a/docs/architektur.md b/docs/architektur.md
index f0824ba..8e3aa36 100644
--- a/docs/architektur.md
+++ b/docs/architektur.md
@@ -423,6 +423,40 @@ dieselbe Stelle. Dass eine Schritt-Bedingung auf **dieselbe Version** zeigt,
haelt ein Trigger: nach dem Veroeffentlichen waere ein fremder Verweis nicht mehr
entscheidbar, und der Abschnitt erschiene stillschweigend immer.
+## 5f-3. Die Vier-Augen-Freigabe: eine Stelle, zwei Netze
+
+Verlangt eine Version eine Freigabe, haengt der Abschluss daran — und die Regel
+dahinter ist **eine Funktion**: `hatDaranGearbeitet` in
+`src/server/services/laeufe.ts`. Sie wird vor jeder Freigabe und vor jeder
+Zurueckweisung gerufen, genau wie `laufMussVeraenderbarSein` vor jeder Aenderung
+an einem Lauf. Zwei Stellen liefen auseinander, und die erste, die es vergisst,
+hebt die Regel auf.
+
+Gezaehlt wird dreierlei: wer den Lauf **angelegt** hat, wer ein
+**Schrittergebnis** gesetzt hat, und wer ihn **abgeschlossen** hat. Der
+Zustaendige gilt bewusst **nicht** als Bearbeiter — eine Zuweisung ist noch keine
+Arbeit, und waere die Regel hier strenger, blockierte sie kleine Teams ohne
+Gewinn.
+
+**Zwei Netze, nicht eines.** Die wichtigste Haelfte steht als CHECK in der
+Datenbank (`Lauf_freigabe_nicht_selbst`): der Ersteller gibt nicht frei, auch
+dann nicht, wenn jemand an der Dienstschicht vorbei schreibt. Der Dienst prueft
+strenger, weil er in zwei weitere Tabellen sehen kann; ein CHECK braeuchte dafuer
+eine Unterabfrage.
+
+**Das Recht `freigeben` hat nicht `TECHNIKER`**, und das ist eine Entscheidung
+gegen Bequemlichkeit: sonst haengt die Vier-Augen-Regel daran, dass zufaellig ein
+zweiter Techniker greifbar ist. Wer sie umgehen will, braucht ein Konto mit mehr
+Rechten — und das steht im Protokoll.
+
+**Die Aufforderung entsteht ausserhalb der Transaktion.** Der Abschlussversuch
+scheitert mit `FreigabeFehlt`; `laufAbschliessen` faengt genau diesen Typ,
+reiht die Meldung an die Freigabeberechtigten ein und wirft weiter. Innerhalb
+der Transaktion wuerde sie mitsamt dem Fehler zurueckgerollt — und dann wartet
+ein Lauf auf etwas, von dem niemand weiss. Ein partieller Unique-Index
+(`Zustellung_freigabe_einmal`) sorgt dafuer, dass mehrere Versuche nicht mehrere
+Mails ergeben.
+
## 5g. Der Verzeichnis-Abgleich: vier Schichten und eine Notbremse
Eine Verzeichnisquelle hat seit #028 eine **Art** — `LDAP`, `GRAPH` oder `SCIM` —,
diff --git a/docs/devlog.md b/docs/devlog.md
index 9e1035f..487f79a 100644
--- a/docs/devlog.md
+++ b/docs/devlog.md
@@ -41,13 +41,14 @@ als Unterpunkte mit Aufgabe und Dauer.
| #038 | Die E2E-Abläufe laufen in keinem Tor | 1 h 35 min |
| #037 | Zwei Nachzügler aus der Durchsicht von Phase 2 | 1 h 50 min |
| #039 | M23 – Bedingte Schritte und Abschnitte | 5 h 20 min |
-| | **Summe** | **79 h 15 min** |
+| #040 | M24 – Vier-Augen-Freigabe | 3 h 30 min |
+| | **Summe** | **82 h 45 min** |
| Meilenstein | Tatsächlich | Status |
|---|---|---|
| v0.1.0 | 35 h 20 min | veröffentlicht |
| v0.2.0 | 38 h 35 min | veröffentlicht |
-| v0.3.0 | 5 h 20 min | in Arbeit |
+| v0.3.0 | 8 h 50 min | in Arbeit |
---
@@ -1839,3 +1840,58 @@ Wirklichkeit nicht — die Prüflisten stehen in `docs/betrieb/entra-id.md` und
- **M24 (#040)** Vier-Augen-Freigabe. Der Abschluss wird dort ohnehin angefasst, und M25 braucht den
Freigeber für das Blatt.
+
+
+---
+
+## 2026-10-24 · Sitzung 26 · 22:30–01:40
+
+**Issue:** #040 (M24)
+**Version:** 0.3.0 (unveröffentlicht)
+
+### Erledigt
+
+- **Vier-Augen-Freigabe.** Neues Recht `freigeben`, Pflicht an der Version, Freigeben und
+ Zurückweisen samt Begründung, Aufforderung an die Berechtigten, Häkchen im Editor, Anzeige an der
+ Versionsliste.
+- 1526 Vitest-Tests (vorher 1496), davon 22 neue in `tests/freigabe.test.ts`.
+- Klickprobe am gebauten Stand: elf Prüfungen, alle grün.
+
+### Entscheidungen
+
+- **Das Recht `freigeben` hat nicht `TECHNIKER`.** Sonst hängt die Vier-Augen-Regel daran, dass
+ zufällig ein zweiter Techniker greifbar ist. Wer sie umgehen will, braucht ein Konto mit mehr
+ Rechten — und das steht im Protokoll.
+- **Das Recht allein genügt nicht.** Wer an dem Lauf gearbeitet hat, gibt ihn nicht frei, auch nicht
+ als Administrator. Die Prüfung sitzt in `hatDaranGearbeitet`, an **einer** Stelle, wie
+ `laufMussVeraenderbarSein`; die wichtigste Hälfte (der Ersteller) hält zusätzlich ein CHECK.
+- **Eine Zuweisung ist noch keine Arbeit.** Wer zugewiesen wurde und nichts getan hat, darf
+ freigeben — wäre die Regel hier strenger, blockierte sie kleine Teams ohne Gewinn.
+- **Kein fünfter `LaufStatus`** für „zurückgewiesen“: der Lauf geht auf `IN_ARBEIT`, die Begründung
+ steht in einem Feld und im Protokoll. Ein neuer Enum-Wert hätte jeden Codepfad betroffen, der
+ heute vier Fälle kennt.
+- **Die Pflicht hängt an der Version**, nicht an der Vorlage: eine veröffentlichte Version ist
+ unveränderlich, ein laufender Lauf behält also seine Regel.
+
+### Probleme
+
+- **Die Aufforderung muss außerhalb der Transaktion entstehen.** Innerhalb würde sie mit dem Fehler
+ zurückgerollt — und dann wartet ein Lauf auf etwas, von dem niemand weiß. Gelöst mit einem eigenen
+ Fehlertyp `FreigabeFehlt`, den `laufAbschliessen` außen fängt; dafür musste die Transaktion in
+ eine eigene Funktion. Ein Zeichenkettenvergleich auf die Meldung wäre eine Zusage über ihren
+ Wortlaut gewesen.
+- **Drei rote Punkte in der Klickprobe waren meine eigenen Testfehler**, und einer ist lehrreich:
+ ein `reload()` direkt nach einer Server Action zeigt kurz den alten Stand, weil es den
+ Router-Cache von Next trifft. Die Action revalidiert den Pfad; ein Benutzer sieht das Ergebnis
+ **ohne** Neuladen. Nachgewiesen, bevor ich etwas geändert habe: in einem frischen Browser-Kontext
+ stand alles richtig da, und die Datenbank trug Freigeber, Zeitpunkt und Notiz.
+ Die beiden anderen: ein Selektor traf versteckten Protokolltext, und ein Suchmuster `/freigeb/`
+ traf nichts — in „Freigabe“ und „freigegeben“ kommt diese Zeichenfolge gar nicht vor. Dreimal
+ gesucht, bis es auffiel.
+- **Die Freigabepflicht wäre bei „Bearbeiten“ verloren gegangen**, hätte #039 es mir nicht gerade
+ gezeigt: dort verwarf die tiefe Kopie `nurFuerAbteilungen` und `bedingung`. Diesmal von Anfang an
+ mitgenommen — und das ist der ganze Wert eines Befundes, der verstanden statt nur behoben wurde.
+
+### Nächster Schritt
+
+- **M25 (#041)** PDF-Übergabeprotokoll. Der Freigeber steht jetzt am Lauf und gehört auf das Blatt.
diff --git a/docs/issues/040-vier-augen-freigabe.md b/docs/issues/040-vier-augen-freigabe.md
index bf1c245..dec6e1b 100644
--- a/docs/issues/040-vier-augen-freigabe.md
+++ b/docs/issues/040-vier-augen-freigabe.md
@@ -1,10 +1,10 @@
# #040 M24 – Vier-Augen-Freigabe
-**Status:** offen
+**Status:** erledigt
**Im Tracker:** –
**Meilenstein:** v0.3.0
**Labels:** phase-3, backend, frontend
-**Geschätzt:** 8 h **Tatsächlich:** –
+**Geschätzt:** 8 h **Tatsächlich:** 3 h 30 min
## Ziel
@@ -17,52 +17,111 @@ ein **Zweiter** ab — nachweisbar im Protokoll.
## Zu entscheiden
-- [ ] **Freigabe am Lauf oder am Schritt?** Empfehlung: **am Lauf**, gesetzt von der Vorlage.
+- [x] **Freigabe am Lauf oder am Schritt?** Empfehlung: **am Lauf**, gesetzt von der Vorlage.
Am Schritt wäre feiner und verführt zu Checklisten, in denen an fünf Stellen jemand warten
muss; am Lauf ist es eine Aussage über die Arbeit als Ganzes und erklärt sich von selbst.
Schritt-Freigaben bleiben möglich, wenn sich der Bedarf zeigt — `VorlagenSchritt.konfig` ist
ein Json.
-- [ ] **Wer darf freigeben?** Empfehlung: ein eigenes Recht `freigeben`, standardmäßig bei `ADMIN`
+- [x] **Wer darf freigeben?** Empfehlung: ein eigenes Recht `freigeben`, standardmäßig bei `ADMIN`
und `REDAKTEUR` — **nicht** bei `TECHNIKER`. Begründung: die Vier-Augen-Regel soll nicht
davon abhängen, dass zufällig ein zweiter Techniker greifbar ist; und wer sie umgehen will,
braucht dann ein zweites Konto mit mehr Rechten, was im Protokoll steht.
-- [ ] **Darf der Bearbeiter sich selbst freigeben?** **Nein**, und das ist der ganze Sinn. Geprüft
+- [x] **Darf der Bearbeiter sich selbst freigeben?** **Nein**, und das ist der ganze Sinn. Geprüft
wird gegen `Schrittergebnis.aktualisiertVonId` **und** `Lauf.erstelltVonId`: wer irgendetwas
daran getan hat, gibt nicht frei. Ein `ADMIN`, der allein im Haus ist, kommt damit nicht
weiter — das ist eine bewusste Härte, und die Anleitung sagt es.
-- [ ] **Was, wenn die Freigabe verweigert wird?** Empfehlung: ein Zustand „zurückgewiesen“ mit
+- [x] **Was, wenn die Freigabe verweigert wird?** Empfehlung: ein Zustand „zurückgewiesen“ mit
Pflichtnotiz. Der Lauf geht zurück auf `IN_ARBEIT`, die Notiz steht im Protokoll und an der
Abarbeitungsansicht. Kein eigener `LaufStatus` — das wäre eine Änderung an einem Enum, das
vier Jahre halten soll; es genügt ein Feld plus Protokolleintrag.
## Aufgaben
-- [ ] Migration: `freigabePflicht Boolean` an `VorlagenVersion`, dazu `freigegebenVonId`,
+- [x] Migration: `freigabePflicht Boolean` an `VorlagenVersion`, dazu `freigegebenVonId`,
`freigegebenAm` und `freigabeNotiz` am `Lauf`; CHECK, dass `freigegebenVonId` und
`freigegebenAm` zusammen gesetzt sind (wie bei `abgeschlossenAm`)
-- [ ] CHECK: `freigegebenVonId` ist **nicht** `erstelltVonId` — die Regel gehört in die Datenbank,
+- [x] CHECK: `freigegebenVonId` ist **nicht** `erstelltVonId` — die Regel gehört in die Datenbank,
nicht nur in den Dienst (CLAUDE.md)
-- [ ] Recht `freigeben` in `RECHTE`, Rollenmatrix-Test erweitern
-- [ ] Dienst: `laufFreigeben(laufId, benutzer, notiz?)` und `laufZurueckweisen(laufId, benutzer,
+- [x] Recht `freigeben` in `RECHTE`, Rollenmatrix-Test erweitern
+- [x] Dienst: `laufFreigeben(laufId, benutzer, notiz?)` und `laufZurueckweisen(laufId, benutzer,
notiz)`; Abschluss verlangt die Freigabe, wenn die Version sie fordert
-- [ ] Die Prüfung „wer hat daran gearbeitet“ an **einer** Stelle, wie `laufMussVeraenderbarSein`
-- [ ] Benachrichtigung: ein neuer `Meldeanlass` `LAUF_FREIGABE_OFFEN` an die Rollen mit dem Recht —
+- [x] Die Prüfung „wer hat daran gearbeitet“ an **einer** Stelle, wie `laufMussVeraenderbarSein`
+- [x] Benachrichtigung: ein neuer `Meldeanlass` `LAUF_FREIGABE_OFFEN` an die Rollen mit dem Recht —
sonst wartet ein Lauf, von dem niemand weiß
-- [ ] Oberfläche: Hinweis am Lauf („wartet auf Freigabe durch einen Zweiten“), Knöpfe für Freigeben
+- [x] Oberfläche: Hinweis am Lauf („wartet auf Freigabe durch einen Zweiten“), Knöpfe für Freigeben
und Zurückweisen, Notizfeld; die Übersicht bekommt einen Filter „wartet auf meine Freigabe“
-- [ ] Editor: Häkchen „dieser Lauf braucht eine Freigabe“ an der Version
-- [ ] Druckansicht und PDF nennen Freigeber und Zeitpunkt
-- [ ] Anleitung: Kapitel 02 (Läufe abarbeiten) und 04 (Vorlagen), mit dem Hinweis, dass ein
+- [x] Editor: Häkchen „dieser Lauf braucht eine Freigabe“ an der Version
+- [x] Druckansicht und PDF nennen Freigeber und Zeitpunkt
+- [x] Anleitung: Kapitel 02 (Läufe abarbeiten) und 04 (Vorlagen), mit dem Hinweis, dass ein
Einzelkämpfer sich nicht selbst freigeben kann
## Akzeptanzkriterien
-- [ ] Test: ein Lauf mit Freigabepflicht lässt sich nicht abschließen, solange keine Freigabe steht
-- [ ] Test: der Bearbeiter kann **nicht** freigeben — auch nicht als `ADMIN`
-- [ ] Test: die Datenbank weist es ebenfalls ab (CHECK), nicht nur der Dienst
-- [ ] Test: eine Rolle ohne `freigeben` wird abgewiesen, über jede Server Action
-- [ ] Test: Zurückweisen verlangt eine Notiz und setzt den Lauf auf `IN_ARBEIT`
-- [ ] Test: die Meldung geht an die Rollen mit dem Recht und nicht an alle
-- [ ] Test: ein Lauf **ohne** Freigabepflicht verhält sich unverändert — bestehende Läufe und
+- [x] Test: ein Lauf mit Freigabepflicht lässt sich nicht abschließen, solange keine Freigabe steht
+- [x] Test: der Bearbeiter kann **nicht** freigeben — auch nicht als `ADMIN`
+- [x] Test: die Datenbank weist es ebenfalls ab (CHECK), nicht nur der Dienst
+- [x] Test: eine Rolle ohne `freigeben` wird abgewiesen, über jede Server Action
+- [x] Test: Zurückweisen verlangt eine Notiz und setzt den Lauf auf `IN_ARBEIT`
+- [x] Test: die Meldung geht an die Rollen mit dem Recht und nicht an alle
+- [x] Test: ein Lauf **ohne** Freigabepflicht verhält sich unverändert — bestehende Läufe und
Vorlagen merken nichts
-- [ ] Am Handy kein horizontales Scrollen
+- [x] Am Handy kein horizontales Scrollen
+
+## Beim Umsetzen dazugekommen
+
+- **Die vier Entscheidungen sind so gefallen wie empfohlen.** Zwei haben sich im Code als besonders
+ tragend erwiesen:
+ - **Die Freigabe hängt an der Version, nicht an der Vorlage.** Eine veröffentlichte Version ist
+ unveränderlich — ein laufender Lauf kann seine Regel damit nicht unter den Füßen verlieren. In
+ der Versionsliste steht an jeder Fassung, ob sie eine Freigabe verlangt.
+ - **Kein fünfter `LaufStatus` für „zurückgewiesen“.** Der Lauf geht auf `IN_ARBEIT`, die
+ Begründung steht in `freigabeNotiz` und im Protokoll. Ein neuer Enum-Wert hätte jeden Codepfad
+ betroffen, der heute vier Fälle kennt.
+- **Als „gearbeitet“ zählt dreierlei** — angelegt, ein Schrittergebnis gesetzt, abgeschlossen.
+ Der **Zuständige** gilt bewusst nicht als Bearbeiter: eine Zuweisung ist noch keine Arbeit, und
+ wäre die Regel hier strenger, blockierte sie kleine Teams ohne Gewinn. Die Prüfung sitzt in
+ `hatDaranGearbeitet`, an einer Stelle — wie `laufMussVeraenderbarSein`.
+- **Die Aufforderung entsteht außerhalb der Transaktion**, und das war die einzige knifflige Stelle:
+ der Abschlussversuch scheitert mit einem eigenen Fehlertyp `FreigabeFehlt`, den
+ `laufAbschliessen` außen fängt, um die Meldung einzureihen und dann weiterzuwerfen. Innerhalb
+ würde sie mit dem Fehler zurückgerollt — und dann wartet ein Lauf auf etwas, von dem niemand
+ weiß. Dafür musste die Transaktion in eine eigene Funktion (`abschlussVersuchen`).
+- **Ein anderer Empfängerkreis als bei jeder anderen Meldung**: nicht der Zuständige, sondern jeder
+ mit dem Recht `freigeben` — ohne die, die gearbeitet haben. Eine Aufforderung zu etwas, das der
+ Dienst abweist, ist schlimmer als keine.
+- **Ein partieller Unique-Index** (`Zustellung_freigabe_einmal`) hält die Mahnung bei einem Lauf
+ und Empfänger auf einmal. Ein Techniker versucht es mehrmals; ohne den Index wäre jede Mail für
+ sich korrekt und in Summe eine Belästigung — derselbe Fall wie „überfällig“ in #034, nur
+ schneller erreicht.
+- **Die Freigabepflicht wandert bei „Bearbeiten“ und „Duplizieren“ mit.** Das war kein Zufall,
+ sondern die unmittelbare Lehre aus #039: dort hatte die tiefe Kopie `nurFuerAbteilungen` und
+ `bedingung` stillschweigend verworfen. Eine Vorlage soll ihre Freigabepflicht nicht durch eine
+ Textkorrektur verlieren.
+
+## Was die Klickprobe gezeigt hat
+
+Elf Prüfungen am gebauten Stand, alle grün — von „Freigabe-Karte sichtbar“ über „Zurückweisen
+verlangt eine Begründung“ bis „Häkchen aus der Vorversion übernommen“ und 390 px.
+
+**Drei rote Punkte waren meine eigenen Testfehler**, und der dritte ist festzuhalten:
+
+1. Ein Selektor traf den **versteckten** Protokolltext „Lauf abgeschlossen“ statt der Erfolgsmeldung.
+2. Ein Suchmuster `/freigeb/` traf nichts: in „Freigabe“ und „freigegeben“ kommt diese Zeichenfolge
+ gar nicht vor. Dreimal gesucht, bis es auffiel.
+3. **Ein `reload()` direkt nach einer Server Action zeigt kurz den alten Stand.** Die Action
+ revalidiert den Pfad, und der Benutzer sieht das Ergebnis ohne Neuladen — ein Reload im selben
+ Kontext trifft dagegen den Router-Cache von Next. Das ist Next und kein Fehler dieser Anwendung,
+ aber es ist auch nicht, was ein Mensch tut. Die Probe prüft jetzt das echte Verhalten.
+
+ Nachgewiesen, bevor etwas geändert wurde: in einem frischen Browser-Kontext stand alles richtig
+ da, und die Datenbank trug Freigeber, Zeitpunkt und Notiz.
+
+## Was offen bleibt
+
+- **Freigaben an einzelnen Schritten** gibt es nicht, bewusst. Am Lauf ist es eine Aussage über die
+ Arbeit als Ganzes und erklärt sich von selbst; am Schritt verführte es zu Checklisten, in denen an
+ fünf Stellen jemand warten muss. `VorlagenSchritt.konfig` ist ein Json — wenn sich der Bedarf
+ zeigt, ist der Platz da.
+- **Ein Einzelkämpfer kommt nicht weiter**: wer allein ist und an dem Lauf gearbeitet hat, kann ihn
+ nicht freigeben, auch nicht als Administrator. Das ist die Härte, die die Regel ausmacht, und die
+ Anleitung sagt es ausdrücklich.
diff --git a/docs/issues/README.md b/docs/issues/README.md
index 9e3df41..e94d2ad 100644
--- a/docs/issues/README.md
+++ b/docs/issues/README.md
@@ -44,7 +44,7 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
| #037 | [Zwei Nachzügler aus der Durchsicht von Phase 2](037-anhaenge-lesemodell.md) | erledigt | v0.2.0 | 2 h | 1 h 50 min |
| #038 | [Die E2E-Abläufe laufen in keinem Tor und waren monatelang rot](038-e2e-im-tor.md) | erledigt | v0.2.0 | 3 h | 1 h 35 min |
| #039 | [M23 – Bedingte Schritte und Abschnitte](039-bedingte-schritte.md) | erledigt | v0.3.0 | 12 h | 5 h 20 min |
-| #040 | [M24 – Vier-Augen-Freigabe](040-vier-augen-freigabe.md) | offen | v0.3.0 | 8 h | – |
+| #040 | [M24 – Vier-Augen-Freigabe](040-vier-augen-freigabe.md) | erledigt | v0.3.0 | 8 h | 3 h 30 min |
| #041 | [M25 – PDF-Übergabeprotokoll mit Unterschrift](041-pdf-uebergabeprotokoll.md) | offen | v0.3.0 | 8 h | – |
| #042 | [M26 – Weitere Schritt-Typen](042-weitere-schritt-typen.md) | offen | v0.3.0 | 6 h | – |
-| | **Summe** | | | **272 h** | **79 h 15 min** |
+| | **Summe** | | | **272 h** | **82 h 45 min** |
diff --git a/prisma/migrations/20261024090000_freigabe/migration.sql b/prisma/migrations/20261024090000_freigabe/migration.sql
new file mode 100644
index 0000000..4df5ac6
--- /dev/null
+++ b/prisma/migrations/20261024090000_freigabe/migration.sql
@@ -0,0 +1,97 @@
+-- ---------------------------------------------------------------------------
+-- HANDGESCHRIEBEN. Prisma erzeugt hier zusaetzlich Loeschbefehle fuer die
+-- zusammengesetzten Fremdschluessel (Mandantengrenze), die Trigramm-Indizes
+-- (Archivsuche), die partiellen eindeutigen Indizes und alle Trigger: es kennt
+-- sie nicht, weil sie sich im Schema nicht ausdruecken lassen. Diese
+-- Loeschbefehle sind bewusst gestrichen.
+--
+-- tests/datenbankregeln.test.ts prueft, dass alle diese Objekte vorhanden sind –
+-- ein versehentlich stehengelassener Loeschbefehl faellt dort sofort auf.
+-- ---------------------------------------------------------------------------
+
+-- Vier-Augen-Freigabe (#040).
+
+/*
+ * Ein neuer Meldeanlass. Ohne ihn wartet ein Lauf auf eine Freigabe, von der
+ * niemand weiss — und das ist der Fall, der die ganze Regel wertlos macht:
+ * sie haelt den Abschluss auf, und keiner merkt es.
+ */
+ALTER TYPE "Meldeanlass" ADD VALUE IF NOT EXISTS 'LAUF_FREIGABE_OFFEN' BEFORE 'ABGLEICH_FEHLGESCHLAGEN';
+
+ALTER TABLE "VorlagenVersion" ADD COLUMN "freigabePflicht" BOOLEAN NOT NULL DEFAULT false;
+
+ALTER TABLE "Lauf"
+ ADD COLUMN "freigegebenVonId" TEXT,
+ ADD COLUMN "freigegebenAm" TIMESTAMP(3),
+ ADD COLUMN "freigabeNotiz" TEXT;
+
+ALTER TABLE "Lauf"
+ ADD CONSTRAINT "Lauf_freigegebenVonId_fkey"
+ FOREIGN KEY ("freigegebenVonId") REFERENCES "Benutzer"("id")
+ ON DELETE RESTRICT ON UPDATE CASCADE;
+
+CREATE INDEX "Lauf_freigegebenVonId_idx" ON "Lauf"("freigegebenVonId");
+
+/*
+ * Freigeber und Zeitpunkt gehoeren zusammen, in beide Richtungen — wie bei
+ * `abgeschlossenAm`. Ohne den Zeitpunkt liesse sich nicht sagen, wann die
+ * Freigabe erteilt wurde, und ein Zeitpunkt ohne Freigeber behauptete eine
+ * Freigabe, die niemand gegeben hat.
+ */
+ALTER TABLE "Lauf"
+ ADD CONSTRAINT "Lauf_freigabe_mit_zeitpunkt"
+ CHECK (("freigegebenVonId" IS NULL) = ("freigegebenAm" IS NULL));
+
+/*
+ * **Der Kern der Vier-Augen-Regel, in der Datenbank.**
+ *
+ * Wer den Lauf angelegt hat, gibt ihn nicht frei. Das prueft auch der Dienst,
+ * und zwar strenger: er sieht ausserdem nach, wer an den Schrittergebnissen
+ * gearbeitet hat (`Schrittergebnis.aktualisiertVonId`). Dafuer braeuchte ein
+ * CHECK eine Unterabfrage, und die gehoert nicht hinein.
+ *
+ * Diese Haelfte steht trotzdem hier, weil sie die wichtigere ist: der Ersteller
+ * ist der naheliegende Fall, und die Regel muss auch dann gelten, wenn jemand an
+ * der Service-Schicht vorbei schreibt (CLAUDE.md). Eine Freigabe, die in der
+ * Datenbank steht, ist damit nachweislich nicht vom Ersteller.
+ */
+ALTER TABLE "Lauf"
+ ADD CONSTRAINT "Lauf_freigabe_nicht_selbst"
+ CHECK ("freigegebenVonId" IS NULL OR "freigegebenVonId" <> "erstelltVonId");
+
+/*
+ * **Die Freigabe-Aufforderung geht je Lauf und Empfaenger genau einmal hinaus.**
+ *
+ * Derselbe Grund wie bei „ueberfaellig“ (#034): sie entsteht bei einem
+ * gescheiterten Abschlussversuch, und ein Techniker versucht es mehrmals — ohne
+ * diesen Index bekaeme jeder Freigabeberechtigte bei jedem Versuch eine Mail,
+ * und jede davon waere fuer sich korrekt.
+ *
+ * Partiell, weil es nur fuer diesen einen Anlass gilt: nach einer Zurueckweisung
+ * und erneuter Fertigstellung soll **nicht** wieder gemahnt werden — der
+ * Zurueckweisende weiss ja, dass er wartet. Dafuer traegt die Zurueckweisung
+ * ihre eigene Notiz an der Abarbeitung.
+ */
+CREATE UNIQUE INDEX "Zustellung_freigabe_einmal"
+ ON "Zustellung"("laufId", "empfaenger")
+ WHERE "anlass" = 'LAUF_FREIGABE_OFFEN';
+
+/*
+ * Gegenprobe: die neuen Spalten sind ueberall leer, und keine bestehende Version
+ * verlangt eine Freigabe. `ALTER TABLE ... ADD CONSTRAINT` prueft das von sich
+ * aus — aber eine Migration, die ihre eigene Annahme nicht nachrechnet, ist
+ * genau die Art Zusage, die dieses Projekt teuer bezahlt hat.
+ */
+DO $$
+DECLARE
+ anzahl INTEGER;
+BEGIN
+ SELECT count(*) INTO anzahl FROM "Lauf" WHERE "freigegebenVonId" IS NOT NULL;
+ IF anzahl > 0 THEN
+ RAISE EXCEPTION 'Unerwartet % Laeufe mit Freigabe vor der Migration.', anzahl;
+ END IF;
+ SELECT count(*) INTO anzahl FROM "VorlagenVersion" WHERE "freigabePflicht";
+ IF anzahl > 0 THEN
+ RAISE EXCEPTION 'Unerwartet % Versionen mit Freigabepflicht vor der Migration.', anzahl;
+ END IF;
+END $$;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 34fb6d7..065adf7 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -162,6 +162,8 @@ enum Meldeanlass {
LAUF_ZUGEWIESEN
LAUF_UEBERFAELLIG
LAUF_ABGESCHLOSSEN
+ /// Ein Lauf wartet auf eine Freigabe durch einen Zweiten (#040).
+ LAUF_FREIGABE_OFFEN
ABGLEICH_FEHLGESCHLAGEN
}
@@ -273,6 +275,7 @@ model Benutzer {
geaendertAm DateTime @default(now()) @updatedAt
abgeschlosseneLaeufe Lauf[] @relation("LaufAbgeschlossenVon")
+ freigegebeneLaeufe Lauf[] @relation("LaufFreigegebenVon")
archivierteLaeufe Lauf[] @relation("LaufArchiviertVon")
erstellteLaeufe Lauf[] @relation("LaufErstelltVon")
zugewieseneLaeufe Lauf[] @relation("LaufZugewiesenAn")
@@ -578,6 +581,13 @@ model VorlagenVersion {
vorlageId String
version Int
status VersionStatus @default(ENTWURF)
+ /// Verlangt dieser Lauf eine Freigabe durch einen Zweiten (#040)?
+ ///
+ /// An der **Version** und nicht an der Vorlage: eine veroeffentlichte Version
+ /// ist unveraenderlich, also kann ein laufender Lauf seine Regel nicht unter
+ /// den Fuessen verlieren. Wer die Pflicht aendert, veroeffentlicht eine neue
+ /// Fassung — bestehende Laeufe behalten ihre.
+ freigabePflicht Boolean @default(false)
aenderungsnotiz String?
veroeffentlichtAm DateTime?
veroeffentlichtVonId String?
@@ -785,6 +795,20 @@ model Lauf {
gestartetAm DateTime?
abgeschlossenAm DateTime?
abgeschlossenVonId String?
+ /// Wer diesen Lauf freigegeben hat (#040), und wann.
+ ///
+ /// **Nicht der Ersteller** — das haelt ein CHECK fest
+ /// (`Lauf_freigabe_nicht_selbst`). Der Dienst prueft strenger: er sieht auch
+ /// nach, wer an den Schrittergebnissen gearbeitet hat; dafuer braeuchte ein
+ /// CHECK eine Unterabfrage.
+ ///
+ /// `freigabeNotiz` traegt die Begruendung — bei einer Freigabe freiwillig, bei
+ /// einer Zurueckweisung Pflicht. Sie bleibt stehen, wenn der Lauf danach
+ /// wieder freigegeben wird: das Protokoll haelt beide Vorgaenge fest, und die
+ /// letzte Notiz ist die, die jemand an der Abarbeitung lesen soll.
+ freigegebenVonId String?
+ freigegebenAm DateTime?
+ freigabeNotiz String?
archiviertAm DateTime?
archiviertVonId String?
erstelltAm DateTime @default(now())
@@ -798,6 +822,7 @@ model Lauf {
erstelltVon Benutzer? @relation("LaufErstelltVon", fields: [erstelltVonId], references: [id], onDelete: Restrict)
zugewiesenAn Benutzer? @relation("LaufZugewiesenAn", fields: [zugewiesenAnId], references: [id], onDelete: Restrict)
abgeschlossenVon Benutzer? @relation("LaufAbgeschlossenVon", fields: [abgeschlossenVonId], references: [id], onDelete: Restrict)
+ freigegebenVon Benutzer? @relation("LaufFreigegebenVon", fields: [freigegebenVonId], references: [id], onDelete: Restrict)
archiviertVon Benutzer? @relation("LaufArchiviertVon", fields: [archiviertVonId], references: [id], onDelete: Restrict)
ergebnisse Schrittergebnis[]
@@ -812,6 +837,7 @@ model Lauf {
@@index([kundeId, status])
@@index([geraetId, status])
@@index([mitarbeiterId])
+ @@index([freigegebenVonId])
@@index([zugewiesenAnId, status])
@@index([status, faelligAm])
@@index([vorlagenVersionId])
diff --git a/src/app/(app)/admin/zustellungen/page.tsx b/src/app/(app)/admin/zustellungen/page.tsx
index 6d8bcd9..195e3d6 100644
--- a/src/app/(app)/admin/zustellungen/page.tsx
+++ b/src/app/(app)/admin/zustellungen/page.tsx
@@ -20,6 +20,7 @@ const ANLASS_TEXT: Record = {
LAUF_ZUGEWIESEN: "zugewiesen",
LAUF_UEBERFAELLIG: "überfällig",
LAUF_ABGESCHLOSSEN: "abgeschlossen",
+ LAUF_FREIGABE_OFFEN: "wartet auf Freigabe",
ABGLEICH_FEHLGESCHLAGEN: "Abgleich fehlgeschlagen",
};
diff --git a/src/app/(app)/laeufe/[id]/page.tsx b/src/app/(app)/laeufe/[id]/page.tsx
index a0a4da0..70c5f48 100644
--- a/src/app/(app)/laeufe/[id]/page.tsx
+++ b/src/app/(app)/laeufe/[id]/page.tsx
@@ -11,6 +11,7 @@ import {
KommentarSchreiben,
LaufAbschliessen,
LaufArchivieren,
+ LaufFreigabe,
LaufBeginnen,
LaufMitAdKontoVerknuepfen,
LaufZurueckholen,
@@ -378,6 +379,36 @@ export default async function LaufSeite({
+ {/*
+ * Die Freigabe-Karte (#040), und zwar **ueber** der Knopfreihe: sie ist
+ * eine Entscheidung und kein Knopf unter anderen.
+ *
+ * Gezeigt wird sie, wenn die Version eine Freigabe verlangt und der Lauf
+ * noch nicht archiviert ist — danach aendert sich nichts mehr. Wer kein
+ * `freigeben` hat, sieht sie nur als Auskunft; das Formular bleibt ihm
+ * verborgen, denn ein Knopf, der nichts tut, ist kein Knopf.
+ */}
+ {lauf.freigabePflicht && !archiviert ? (
+ darf(benutzer.rolle, "freigeben") ? (
+
+
+
+ ) : (
+
+ {lauf.freigegebenAm
+ ? `Freigegeben${lauf.freigegebenVon ? ` von ${lauf.freigegebenVon}` : ""}.`
+ : "Dieser Lauf verlangt eine Freigabe durch einen Zweiten, bevor er abgeschlossen " +
+ "werden kann. Die Freigabeberechtigten sind benachrichtigt, sobald du ihn " +
+ "abzuschließen versuchst."}
+ {lauf.freigabeNotiz ? ` Letzte Begründung: ${lauf.freigabeNotiz}` : ""}
+
+ )
+ ) : null}
+
{lauf.status === LaufStatus.OFFEN && darfArbeiten ? : null}
{lauf.status === LaufStatus.IN_ARBEIT && darfArbeiten ? (
diff --git a/src/app/(app)/vorlagen/[id]/page.tsx b/src/app/(app)/vorlagen/[id]/page.tsx
index aaf57fa..0664d28 100644
--- a/src/app/(app)/vorlagen/[id]/page.tsx
+++ b/src/app/(app)/vorlagen/[id]/page.tsx
@@ -1,6 +1,6 @@
import Link from "next/link";
import { notFound } from "next/navigation";
-import { ArrowLeft, Eye } from "lucide-react";
+import { ArrowLeft, Eye, ShieldCheck } from "lucide-react";
import { VorlageKopfdaten } from "@/components/vorlage-kopfdaten";
import { VorlageBearbeitenKnopf } from "@/components/vorlage-bearbeiten-knopf";
import {
@@ -50,6 +50,17 @@ function HistorienZeile({ zeile, darfBearbeiten }: { zeile: VersionsZeile; darfB
{zeile.anzahlLaeufe} {zeile.anzahlLaeufe === 1 ? "Lauf" : "Läufe"}
+ {/*
+ Steht an der **Version**, nicht an der Vorlage (#040): verschiedene
+ Versionen koennen es verschieden halten, und ein laufender Lauf behaelt
+ die Regel seiner Version.
+ */}
+ {zeile.freigabePflicht ? (
+
+
+ Freigabe nötig
+
+ ) : null}
{zeile.aenderungsnotiz ? (
{zeile.aenderungsnotiz}
diff --git a/src/app/(app)/vorlagen/version/[versionId]/page.tsx b/src/app/(app)/vorlagen/version/[versionId]/page.tsx
index dd993bf..5d17c72 100644
--- a/src/app/(app)/vorlagen/version/[versionId]/page.tsx
+++ b/src/app/(app)/vorlagen/version/[versionId]/page.tsx
@@ -58,6 +58,7 @@ export default async function VersionSeite({ params }: { params: Promise<{ versi
struktur: version.struktur,
stand: version.stand.toISOString(),
aenderungsnotiz: version.aenderungsnotiz,
+ freigabePflicht: version.freigabePflicht,
}}
lesend={veroeffentlicht || !darfBearbeiten}
darfBearbeiten={darfBearbeiten}
diff --git a/src/components/laeufe/lauf-aktionen.tsx b/src/components/laeufe/lauf-aktionen.tsx
index cb17bdd..8869c2f 100644
--- a/src/components/laeufe/lauf-aktionen.tsx
+++ b/src/components/laeufe/lauf-aktionen.tsx
@@ -1,11 +1,13 @@
"use client";
import { useActionState, useState } from "react";
-import { Archive, CheckCircle2, Link2, PlayCircle, Undo2 } from "lucide-react";
+import { Archive, CheckCircle2, Link2, PlayCircle, ShieldCheck, Undo2 } from "lucide-react";
import {
abschnittNichtZutreffendAktion,
kommentarHinzufuegenAktion,
laufAbschliessenAktion,
+ laufFreigebenAktion,
+ laufZurueckweisenAktion,
laufArchivierenAktion,
laufMitAdKontoVerknuepfenAktion,
laufOeffnenAktion,
@@ -93,6 +95,107 @@ export function LaufAbschliessen({ laufId, offen }: { laufId: string; offen: num
);
}
+/**
+ * Freigeben und Zurueckweisen (#040).
+ *
+ * **Eine Komponente fuer beides**, weil es dieselbe Entscheidung ist: der
+ * Freigebende sieht den Lauf an und sagt ja oder nein. Zwei getrennte Karten
+ * haetten dieselbe Notiz zweimal.
+ *
+ * Die Notiz ist bei der Freigabe freiwillig und bei der Zurueckweisung Pflicht —
+ * geprueft wird das in der Server Action und im Dienst, nicht hier: eine Regel,
+ * die nur das Formular kennt, gilt nicht fuer den naechsten Aufrufer.
+ */
+export function LaufFreigabe({
+ laufId,
+ bereitsFreigegeben,
+ letzteNotiz,
+}: {
+ laufId: string;
+ bereitsFreigegeben: boolean;
+ /** Die letzte Begruendung – nach einer Zurueckweisung steht sie hier. */
+ letzteNotiz: string | null;
+}) {
+ const [freigabe, freigeben, freigabeLaeuft] = useActionState(
+ laufFreigebenAktion,
+ {},
+ );
+ const [rueckweisung, zurueckweisen, rueckweisungLaeuft] = useActionState(
+ laufZurueckweisenAktion,
+ {},
+ );
+ const [notiz, setzeNotiz] = useState("");
+
+ return (
+
+
+
+
+ {bereitsFreigegeben ? (
+ <>Dieser Lauf ist freigegeben. Zurückweisen nimmt die Freigabe wieder zurück.>
+ ) : (
+ <>
+ Dieser Lauf verlangt eine Freigabe durch einen Zweiten. Wer daran gearbeitet
+ hat, kann sie nicht erteilen.
+ >
+ )}
+
+
+
+ {letzteNotiz ? (
+
+ Letzte Begründung: {letzteNotiz}
+
+ ) : null}
+
+
+
+
+ {bereitsFreigegeben ? null : (
+
+ )}
+
+
+
+
+
+
+
+ );
+}
+
/**
* Archivieren – die zweite, ausdrueckliche Handlung.
*
diff --git a/src/components/vorlagen/editor.tsx b/src/components/vorlagen/editor.tsx
index 9231e15..181a3fb 100644
--- a/src/components/vorlagen/editor.tsx
+++ b/src/components/vorlagen/editor.tsx
@@ -26,6 +26,7 @@ import {
type EditorVariable,
} from "@/lib/editor-aufbau";
import { unbekanntePlatzhalter } from "@/lib/platzhalter";
+import { KAESTCHEN } from "@/lib/formular";
import { GRENZEN, type Struktur } from "@/lib/vorlagen-format";
import { entwurfSpeichernAktion, veroeffentlichenAktion } from "@/server/actions/vorlagen";
@@ -52,6 +53,8 @@ export type EditorVersion = {
/** Stand beim Laden als ISO-Zeichenkette; geht beim Speichern zurueck. */
readonly stand: string;
readonly aenderungsnotiz: string | null;
+ /** Verlangen Laeufe dieser Version eine Freigabe (#040)? */
+ readonly freigabePflicht: boolean;
};
/**
@@ -467,6 +470,32 @@ export function Editor({