diff --git a/CHANGELOG.md b/CHANGELOG.md
index e644f43..32087f8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -26,6 +26,14 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Hinzugefügt
+- **Wiederverwendbare Bausteine.** Ein Abschnitt, den mehrere Vorlagen teilen und der an einer
+ Stelle gepflegt wird — „Datensicherung prüfen“ steht in vier der sieben mitgelieferten Vorlagen,
+ wortgleich. Versioniert nach denselben Regeln wie eine Vorlage: höchstens ein Entwurf, eine
+ veröffentlichte Fassung wird nie verändert. Eingesetzt wird ein Baustein im Editor; beim
+ **Veröffentlichen** einer Vorlage setzt der Dienst seine Schritte als tiefe Kopie ein. Damit
+ ändert eine neue Baustein-Fassung **keine** veröffentlichte Vorlagenversion und **keinen**
+ laufenden Lauf — sie wirkt erst in der nächsten veröffentlichten Version. Auf der Seite eines
+ Bausteins steht, in welchen Vorlagen er vorkommt und welche Fassung dort eingesetzt ist (#030)
- **Das Protokoll ist ansehbar**, unter Verwaltung → Protokoll. Geschrieben wurde es seit dem
Datenmodell, ansehen konnte es niemand: wer wissen wollte, wer wann eine Rolle geändert hat,
brauchte `psql`. Jetzt mit Volltext über Aktion und Lauftitel, Filter nach Urheber und Zeitraum,
diff --git a/docs/anleitung/04-vorlagen.md b/docs/anleitung/04-vorlagen.md
index e09d963..88474fc 100644
--- a/docs/anleitung/04-vorlagen.md
+++ b/docs/anleitung/04-vorlagen.md
@@ -190,6 +190,55 @@ und für `{{generiertes_passwort}}` entsteht **kein** Passwort — dort steht
Nutz sie vor dem Veröffentlichen, besonders bei Befehlen: erst hier sieht man, ob der fertige Befehl
so aussieht, wie er aussehen soll.
+## Bausteine: ein Abschnitt für mehrere Vorlagen
+
+„Datensicherung prüfen“ steht in vier der sieben mitgelieferten Vorlagen, wortgleich. Wer den Ablauf
+ändert, ändert ihn viermal — oder dreimal und vergisst den vierten. Ein **Baustein** ist ein
+Abschnitt, den mehrere Vorlagen teilen und der an einer Stelle gepflegt wird.
+
+Die Liste steht hinter dem Knopf **Bausteine** oben auf der Vorlagenseite. Ein Baustein wird dort
+angelegt (nur ein Name), bekommt dann einen Titel und seine Schritte, und wird **veröffentlicht** —
+genau wie eine Vorlage, mit denselben Regeln: höchstens ein Entwurf, und eine veröffentlichte
+Fassung wird nie wieder verändert.
+
+### Einsetzen
+
+Im Editor einer Vorlage steht unten neben **Abschnitt hinzufügen** das Feld **Baustein einsetzen**.
+Angeboten werden nur veröffentlichte Fassungen nicht archivierter Bausteine. Der Abschnitt erscheint
+dann als Einheit: mit dem Kennzeichen „Baustein“, dem Namen der Quelle und der Zahl seiner Schritte.
+
+**Titel und Schritte lassen sich dort nicht ändern.** Beides kommt beim Veröffentlichen aus dem
+Baustein — ein Feld, das man ausfüllen kann und das danach überschrieben wird, wäre eine Falle.
+Geändert wird am Baustein.
+
+### Was eine Änderung am Baustein bewirkt — und was nicht
+
+Das ist der Punkt, den man einmal verstanden haben muss:
+
+- **Schon veröffentlichte Vorlagenversionen ändern sich nicht.** Sie tragen beim Veröffentlichen
+ eingesetzte **Kopien** der Schritte. Ein Lauf von vor zwei Jahren zeigt genau das, was damals
+ abgearbeitet wurde — daran ändert kein Baustein etwas.
+- **Laufende Arbeiten ändern sich nicht.** Aus demselben Grund.
+- **Die neue Fassung wirkt in der nächsten veröffentlichten Version** einer Vorlage. Wer sie dort
+ haben will, legt einen neuen Entwurf an und veröffentlicht ihn.
+
+Auf der Seite eines Bausteins steht unter **Wo er steckt**, in welchen Vorlagen er vorkommt und
+welche **Fassung** dort eingesetzt ist. Vor einer Änderung ist das die Liste, die man lesen will.
+
+### Archivieren
+
+Gelöscht wird ein Baustein nie: veröffentlichte Vorlagenversionen tragen seine Herkunft. Archiviert
+lässt er sich nicht mehr neu einsetzen, bleibt aber in jeder Vorlage lesbar, die ihn schon benutzt.
+Steht er noch in einem **Entwurf**, weist Laufzettel das Archivieren ab — diese Vorlage ließe sich
+danach nicht mehr veröffentlichen.
+
+### In einer Austauschdatei steht kein Baustein
+
+Ein Export trägt die **aufgelösten** Schritte, keinen Verweis. Eine Kennung aus dieser Installation
+bedeutet in einer anderen nichts, und ein Verweis auf einen Baustein, den es dort nicht gibt, wäre
+eine Vorlage, die sich nicht veröffentlichen lässt. Wer einen Baustein mitgeben will, exportiert die
+Vorlage und legt den Baustein in der anderen Installation neu an.
+
## Veröffentlichen
Im Abschnitt **Veröffentlichen** trägst du eine **Änderungsnotiz (optional)** ein — ein Satz, was
diff --git a/docs/issues/030-bausteine.md b/docs/issues/030-bausteine.md
index b7d070c..f5b38b0 100644
--- a/docs/issues/030-bausteine.md
+++ b/docs/issues/030-bausteine.md
@@ -1,10 +1,10 @@
# #030 M17 – Wiederverwendbare Bausteine
-**Status:** offen
+**Status:** erledigt
**Im Tracker:** [#50](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/50)
**Meilenstein:** v0.2.0
**Labels:** phase-2, backend, frontend
-**Geschätzt:** 12 h **Tatsächlich:** –
+**Geschätzt:** 12 h **Tatsächlich:** 2 h 25 min
## Ziel
@@ -20,7 +20,7 @@ wurden. Ein Baustein, der sich ändert, darf das nicht antasten.
## Zu entscheiden
-- [ ] **Baustein mit eigener Version, beim Veröffentlichen tief kopiert** — Empfehlung. Ein
+- [x] **Baustein mit eigener Version, beim Veröffentlichen tief kopiert** — Empfehlung. Ein
Entwurf **verweist** auf einen Baustein; beim Veröffentlichen löst der Dienst den Verweis in
echte Abschnitte und Schritte auf. Damit bleibt alles, was danach kommt, unberührt: `Lauf`,
`Schrittergebnis`, Druckansicht und Archiv sehen weiterhin konkrete Schritte und müssen nichts
@@ -28,32 +28,55 @@ wurden. Ein Baustein, der sich ändert, darf das nicht antasten.
Die Alternative — Läufe verweisen zur Laufzeit auf den Baustein — hieße, dass die Unveränder-
lichkeit an einer zweiten Stelle durchgesetzt werden muss, und dass jede Abfrage über Schritte
zwei Wege kennen muss. Das ist der Preis für eine Bequemlichkeit, die niemand verlangt hat.
-- [ ] **Was „Baustein aktualisieren“ für laufende Arbeiten bedeutet:** nichts. Eine neue
+- [x] **Was „Baustein aktualisieren“ für laufende Arbeiten bedeutet:** nichts. Eine neue
Baustein-Version wirkt erst in der **nächsten** veröffentlichten Vorlagenversion. Das muss die
Oberfläche sagen, sonst erwartet man Durchschlag.
## Aufgaben
-- [ ] Modelle `Baustein` und `BausteinVersion` mit denselben Regeln wie bei Vorlagen: höchstens ein
+- [x] Modelle `Baustein` und `BausteinVersion` mit denselben Regeln wie bei Vorlagen: höchstens ein
Entwurf, veröffentlichte Version unveränderlich (Trigger, nicht nur Dienst)
-- [ ] `VorlagenAbschnitt` kann statt eigener Schritte einen Baustein nennen — nur im **Entwurf**
-- [ ] Veröffentlichen löst auf: tiefe Kopie der Baustein-Abschnitte und -Schritte in die Version,
+- [x] `VorlagenAbschnitt` kann statt eigener Schritte einen Baustein nennen — nur im **Entwurf**
+- [x] Veröffentlichen löst auf: tiefe Kopie der Baustein-Abschnitte und -Schritte in die Version,
mit Vermerk, aus welchem Baustein und welcher Version sie stammen
-- [ ] Editor: Baustein einfügen, als Einheit anzeigen, nicht Schritt für Schritt bearbeitbar; ein
+- [x] Editor: Baustein einfügen, als Einheit anzeigen, nicht Schritt für Schritt bearbeitbar; ein
Hinweis nennt Baustein und Version
-- [ ] Eigene Verwaltung für Bausteine, Recht `vorlagenBearbeiten` (wer Vorlagen pflegt, pflegt
+- [x] Eigene Verwaltung für Bausteine, Recht `vorlagenBearbeiten` (wer Vorlagen pflegt, pflegt
Bausteine)
-- [ ] „Wo wird dieser Baustein benutzt“ — vor dem Ändern will man das wissen
-- [ ] Anleitung Kapitel 04
+- [x] „Wo wird dieser Baustein benutzt“ — vor dem Ändern will man das wissen
+- [x] Anleitung Kapitel 04
## Akzeptanzkriterien
-- [ ] Test: eine neue Baustein-Version ändert **keine** veröffentlichte Vorlagenversion und **keinen**
+- [x] Test: eine neue Baustein-Version ändert **keine** veröffentlichte Vorlagenversion und **keinen**
laufenden Lauf
-- [ ] Test: die Druckansicht eines archivierten Laufs ist nach einer Baustein-Änderung Zeichen für
+- [x] Test: die Druckansicht eines archivierten Laufs ist nach einer Baustein-Änderung Zeichen für
Zeichen dieselbe
-- [ ] Test: die nächste veröffentlichte Vorlagenversion enthält die neue Baustein-Version
-- [ ] Test: ein Baustein lässt sich nicht löschen, solange eine Vorlage ihn im Entwurf nennt
-- [ ] Test: Export und Import einer Vorlage mit Baustein ergeben eine inhaltsgleiche Vorlage — die
+- [x] Test: die nächste veröffentlichte Vorlagenversion enthält die neue Baustein-Version
+- [x] Test: ein Baustein lässt sich nicht löschen, solange eine Vorlage ihn im Entwurf nennt
+- [x] Test: Export und Import einer Vorlage mit Baustein ergeben eine inhaltsgleiche Vorlage — die
Datei trägt die aufgelösten Schritte, keinen Verweis auf einen Baustein, den die andere
Installation nicht hat
+
+## Beim Umsetzen dazugekommen
+
+- **Ein Baustein ist genau ein Abschnitt**, nicht eine Gruppe davon. Das stand so im Auftrag
+ („Abschnitte, die mehrere Vorlagen teilen“) und spart eine Verschachtelungsebene, die niemand
+ gebraucht hätte.
+- **Der Verweis bleibt nach dem Veröffentlichen stehen** und ist dann nur noch Herkunft. Welche der
+ beiden Lesarten gilt, sagt der Status der Version — ein zweites Feld dafür wäre eine zweite
+ Wahrheit. Die Seite eines Bausteins liest daraus „Wo er steckt“, mit der Fassung je Vorlage.
+- **Der Titel kommt aus dem Baustein**, nicht aus dem Abschnitt der Vorlage. Sonst müsste man ihn in
+ jeder Vorlage einzeln pflegen, und zwei Vorlagen zeigten denselben Baustein unter verschiedenen
+ Namen.
+- **Vier Garantien liegen in der Datenbank**, nicht nur im Dienst: höchstens ein Entwurf (partieller
+ Index), eine veröffentlichte Fassung ist unveränderlich (Trigger), der Weg zurück zum Entwurf ist
+ versperrt (Trigger), und ein Abschnitt kann nur auf eine **veröffentlichte** Fassung verweisen
+ (Trigger). Beim Aufräumen der Testdaten hat der zweite sofort zugeschlagen — gut so.
+- **Das Durchklicken des gebauten Stands fand einen Fehler**, den die Suite nicht gesehen hätte:
+ `neuerBausteinAbschnitt` legt Platzhalter-Schritte **ohne Titel** an, damit die Anzahl in der Karte
+ stimmt. `alsStruktur` schickte sie mit, und `SchrittSchema` wies den ganzen Entwurf ab — das
+ Speichern scheiterte. Behoben, mit Test und Gegenprobe.
+- Dabei habe ich **zweimal mein eigenes Prüfskript** für den Fehler gehalten: einmal griff ein zu
+ weiter `role=status`-Selektor den falschen Text, einmal eine Prüfung nach dem Neuladen. Die
+ Datenbank hat beides geklärt — sie ist der einzige Zeuge, der nicht lügt.
diff --git a/docs/issues/README.md b/docs/issues/README.md
index 9423453..7c7d859 100644
--- a/docs/issues/README.md
+++ b/docs/issues/README.md
@@ -34,10 +34,10 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
| #027 | [Die Editor-Abläufe lassen ihre Vorlagen stehen](027-e2e-reste-vorlagen.md) | offen | v0.2.0 | 1 h | – |
| #028 | [M15 – Quellenarten und Microsoft-Graph-Quelle](028-graph-quelle.md) | offen | v0.2.0 | 10 h | – |
| #029 | [M16 – SCIM-Endpunkt als Alternative zum Abgleich](029-scim-endpunkt.md) | offen | v0.2.0 | 12 h | – |
-| #030 | [M17 – Wiederverwendbare Bausteine](030-bausteine.md) | offen | v0.2.0 | 12 h | – |
+| #030 | [M17 – Wiederverwendbare Bausteine](030-bausteine.md) | erledigt | v0.2.0 | 12 h | 2 h 25 min |
| #031 | [M18 – Abteilungsspezifische Zusatzabschnitte](031-abteilungsabschnitte.md) | offen | v0.2.0 | 8 h | – |
| #032 | [M19 – Anhänge und Fotos an Schritten](032-anhaenge.md) | offen | v0.2.0 | 10 h | – |
| #033 | [M20 – Live-Aktualisierung per SSE statt Polling](033-sse-live.md) | offen | v0.2.0 | 6 h | – |
| #034 | [M21 – Benachrichtigungen per Mail und Webhook](034-benachrichtigungen.md) | offen | v0.2.0 | 10 h | – |
| #035 | [M22 – Mitarbeiter-Selbstauskunft](035-selbstauskunft.md) | offen | v0.2.0 | 8 h | – |
-| | **Summe** | | | **229 h** | **41 h 20 min** |
+| | **Summe** | | | **229 h** | **43 h 45 min** |
diff --git a/prisma/migrations/20261004200000_bausteine/migration.sql b/prisma/migrations/20261004200000_bausteine/migration.sql
new file mode 100644
index 0000000..386b8c7
--- /dev/null
+++ b/prisma/migrations/20261004200000_bausteine/migration.sql
@@ -0,0 +1,160 @@
+-- ---------------------------------------------------------------------------
+-- HANDGESCHRIEBEN. Prisma erzeugt hier zusaetzlich Loeschbefehle fuer die
+-- zusammengesetzten Fremdschluessel (Mandantengrenze), die Trigramm-Indizes
+-- (Archivsuche), die partiellen eindeutigen Indizes und die Trigger auf
+-- veroeffentlichten Versionen: 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.
+-- ---------------------------------------------------------------------------
+
+-- Wiederverwendbare Bausteine (#030).
+
+CREATE TABLE "Baustein" (
+ "id" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "beschreibung" TEXT,
+ "archiviert" BOOLEAN NOT NULL DEFAULT false,
+ "erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "geaendertAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT "Baustein_pkey" PRIMARY KEY ("id")
+);
+
+CREATE UNIQUE INDEX "Baustein_name_key" ON "Baustein"("name");
+CREATE INDEX "Baustein_archiviert_idx" ON "Baustein"("archiviert");
+
+CREATE TABLE "BausteinVersion" (
+ "id" TEXT NOT NULL,
+ "bausteinId" TEXT NOT NULL,
+ "version" INTEGER NOT NULL,
+ "status" "VersionStatus" NOT NULL DEFAULT 'ENTWURF',
+ "titel" TEXT NOT NULL,
+ "aenderungsnotiz" TEXT,
+ "veroeffentlichtAm" TIMESTAMP(3),
+ "erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "geaendertAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT "BausteinVersion_pkey" PRIMARY KEY ("id")
+);
+
+CREATE UNIQUE INDEX "BausteinVersion_bausteinId_version_key"
+ ON "BausteinVersion"("bausteinId", "version");
+CREATE INDEX "BausteinVersion_bausteinId_status_idx"
+ ON "BausteinVersion"("bausteinId", "status");
+
+CREATE TABLE "BausteinSchritt" (
+ "id" TEXT NOT NULL,
+ "versionId" TEXT NOT NULL,
+ "position" INTEGER NOT NULL DEFAULT 0,
+ "titel" TEXT NOT NULL,
+ "hilfetext" TEXT,
+ "typ" "SchrittTyp" NOT NULL DEFAULT 'HAKEN',
+ "pflicht" BOOLEAN NOT NULL DEFAULT false,
+ "konfig" JSONB NOT NULL DEFAULT '{}',
+ "dokuUrl" TEXT,
+ CONSTRAINT "BausteinSchritt_pkey" PRIMARY KEY ("id")
+);
+
+CREATE INDEX "BausteinSchritt_versionId_position_idx"
+ ON "BausteinSchritt"("versionId", "position");
+
+ALTER TABLE "BausteinVersion"
+ ADD CONSTRAINT "BausteinVersion_bausteinId_fkey"
+ FOREIGN KEY ("bausteinId") REFERENCES "Baustein"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+ALTER TABLE "BausteinSchritt"
+ ADD CONSTRAINT "BausteinSchritt_versionId_fkey"
+ FOREIGN KEY ("versionId") REFERENCES "BausteinVersion"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- Der Verweis am Abschnitt. RESTRICT, nicht CASCADE: eine Baustein-Version, auf
+-- die eine Vorlage zeigt, darf nicht unter ihr wegbrechen.
+ALTER TABLE "VorlagenAbschnitt"
+ ADD COLUMN "bausteinVersionId" TEXT;
+
+CREATE INDEX "VorlagenAbschnitt_bausteinVersionId_idx"
+ ON "VorlagenAbschnitt"("bausteinVersionId");
+
+ALTER TABLE "VorlagenAbschnitt"
+ ADD CONSTRAINT "VorlagenAbschnitt_bausteinVersionId_fkey"
+ FOREIGN KEY ("bausteinVersionId") REFERENCES "BausteinVersion"("id")
+ ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- ---------------------------------------------------------------------------
+-- Dieselben Garantien wie bei Vorlagen, und zwar in der Datenbank.
+--
+-- Ein Baustein ist eine Quelle fuer Schritte, die in Laeufe wandern. Stuende
+-- seine Unveraenderlichkeit nur im Dienst, liesse sich die Nachvollziehbarkeit
+-- des Archivs ueber eine Baustein-Aenderung aushebeln.
+-- ---------------------------------------------------------------------------
+
+-- Je Baustein hoechstens ein Entwurf.
+CREATE UNIQUE INDEX "BausteinVersion_ein_entwurf"
+ ON "BausteinVersion" ("bausteinId")
+ WHERE "status" = 'ENTWURF';
+
+-- Eine veroeffentlichte Baustein-Version ist unveraenderlich.
+CREATE OR REPLACE FUNCTION laufzettel_baustein_unveraenderlich() RETURNS trigger AS $$
+DECLARE
+ betroffene_version TEXT;
+ aktueller_status TEXT;
+BEGIN
+ betroffene_version := COALESCE(NEW."versionId", OLD."versionId");
+ SELECT status::TEXT INTO aktueller_status
+ FROM "BausteinVersion" WHERE id = betroffene_version;
+
+ IF aktueller_status = 'VEROEFFENTLICHT' THEN
+ RAISE EXCEPTION
+ 'Baustein-Version % ist veroeffentlicht und unveraenderlich (%).',
+ betroffene_version, TG_TABLE_NAME
+ USING ERRCODE = 'integrity_constraint_violation';
+ END IF;
+ RETURN COALESCE(NEW, OLD);
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER "BausteinSchritt_unveraenderlich"
+ BEFORE INSERT OR UPDATE OR DELETE ON "BausteinSchritt"
+ FOR EACH ROW EXECUTE FUNCTION laufzettel_baustein_unveraenderlich();
+
+-- Der Weg zurueck waere die Hintertuer, durch die sich das Obige aushebeln
+-- liesse. Dieselbe Regel wie bei VorlagenVersion.
+CREATE OR REPLACE FUNCTION laufzettel_baustein_status_einbahn() RETURNS trigger AS $$
+BEGIN
+ IF OLD.status = 'VEROEFFENTLICHT' AND NEW.status <> 'VEROEFFENTLICHT' THEN
+ RAISE EXCEPTION
+ 'Eine veroeffentlichte Baustein-Version laesst sich nicht wieder zum Entwurf machen.'
+ USING ERRCODE = 'integrity_constraint_violation';
+ END IF;
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER "BausteinVersion_status_einbahn"
+ BEFORE UPDATE ON "BausteinVersion"
+ FOR EACH ROW EXECUTE FUNCTION laufzettel_baustein_status_einbahn();
+
+-- Ein Verweis zeigt nur auf eine **veroeffentlichte** Baustein-Version. Einen
+-- Entwurf einzusetzen hiesse, beim Veroeffentlichen etwas zu kopieren, das sich
+-- danach noch aendert.
+CREATE OR REPLACE FUNCTION laufzettel_baustein_verweis_veroeffentlicht() RETURNS trigger AS $$
+DECLARE
+ verwiesener_status TEXT;
+BEGIN
+ IF NEW."bausteinVersionId" IS NULL THEN
+ RETURN NEW;
+ END IF;
+ SELECT status::TEXT INTO verwiesener_status
+ FROM "BausteinVersion" WHERE id = NEW."bausteinVersionId";
+
+ IF verwiesener_status <> 'VEROEFFENTLICHT' THEN
+ RAISE EXCEPTION
+ 'Ein Abschnitt kann nur auf eine veroeffentlichte Baustein-Version verweisen.'
+ USING ERRCODE = 'integrity_constraint_violation';
+ END IF;
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER "VorlagenAbschnitt_baustein_veroeffentlicht"
+ BEFORE INSERT OR UPDATE ON "VorlagenAbschnitt"
+ FOR EACH ROW EXECUTE FUNCTION laufzettel_baustein_verweis_veroeffentlicht();
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 0bc0a6f..5d20196 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -464,13 +464,83 @@ model VorlagenVariable {
}
model VorlagenAbschnitt {
- id String @id @default(cuid())
- versionId String
- titel String
- position Int @default(0)
+ id String @id @default(cuid())
+ versionId String
+ titel String
+ position Int @default(0)
+ /// Verweis auf einen wiederverwendbaren Baustein (#030).
+ ///
+ /// In einem **Entwurf** heisst das: beim Veroeffentlichen hier die Schritte des
+ /// Bausteins einsetzen. In einer **veroeffentlichten** Version heisst dasselbe
+ /// Feld: diese Schritte stammen von dort — es ist dann nur noch Herkunft.
+ /// Welche der beiden Lesarten gilt, sagt der Status der Version; ein zweites
+ /// Feld dafuer waere eine zweite Wahrheit.
+ bausteinVersionId String?
- version VorlagenVersion @relation(fields: [versionId], references: [id], onDelete: Cascade)
- schritte VorlagenSchritt[]
+ version VorlagenVersion @relation(fields: [versionId], references: [id], onDelete: Cascade)
+ bausteinVersion BausteinVersion? @relation(fields: [bausteinVersionId], references: [id], onDelete: Restrict)
+ schritte VorlagenSchritt[]
+
+ @@index([versionId, position])
+ @@index([bausteinVersionId])
+}
+
+// ---- Bausteine -------------------------------------------------------------
+
+/// Ein Abschnitt, den mehrere Vorlagen teilen (#030).
+///
+/// Versioniert nach denselben Regeln wie eine Vorlage: hoechstens ein Entwurf,
+/// eine veroeffentlichte Version wird nie veraendert. Beim Veroeffentlichen
+/// einer Vorlage wird der Baustein **tief kopiert** — ein Lauf sieht damit
+/// weiterhin konkrete Schritte, und Schrittergebnis, Druckansicht und Archiv
+/// muessen nichts lernen.
+model Baustein {
+ id String @id @default(cuid())
+ /// Eindeutig: der Name ist, wonach ein Redakteur ihn sucht.
+ name String @unique
+ beschreibung String?
+ archiviert Boolean @default(false)
+ erstelltAm DateTime @default(now())
+ geaendertAm DateTime @default(now()) @updatedAt
+
+ versionen BausteinVersion[]
+
+ @@index([archiviert])
+}
+
+model BausteinVersion {
+ id String @id @default(cuid())
+ bausteinId String
+ version Int
+ status VersionStatus @default(ENTWURF)
+ /// Titel des Abschnitts, der daraus entsteht. Steht an der Version und nicht
+ /// am Baustein, damit er sich aendern laesst, ohne alte Vorlagen umzuschreiben.
+ titel String
+ aenderungsnotiz String?
+ veroeffentlichtAm DateTime?
+ erstelltAm DateTime @default(now())
+ geaendertAm DateTime @default(now()) @updatedAt
+
+ baustein Baustein @relation(fields: [bausteinId], references: [id], onDelete: Cascade)
+ schritte BausteinSchritt[]
+ verwendungen VorlagenAbschnitt[]
+
+ @@unique([bausteinId, version])
+ @@index([bausteinId, status])
+}
+
+model BausteinSchritt {
+ id String @id @default(cuid())
+ versionId String
+ position Int @default(0)
+ titel String
+ hilfetext String?
+ typ SchrittTyp @default(HAKEN)
+ pflicht Boolean @default(false)
+ konfig Json @default("{}")
+ dokuUrl String?
+
+ version BausteinVersion @relation(fields: [versionId], references: [id], onDelete: Cascade)
@@index([versionId, position])
}
diff --git a/src/app/(app)/vorlagen/bausteine/[id]/page.tsx b/src/app/(app)/vorlagen/bausteine/[id]/page.tsx
new file mode 100644
index 0000000..3a4f0d9
--- /dev/null
+++ b/src/app/(app)/vorlagen/bausteine/[id]/page.tsx
@@ -0,0 +1,184 @@
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { ArrowLeft, Blocks } from "lucide-react";
+import { BausteinEditor } from "@/components/bausteine/baustein-editor";
+import { BausteinKopf } from "@/components/bausteine/baustein-kopf";
+import { VersionStatus } from "@/generated/prisma/enums";
+import { darf } from "@/server/auth/rollen";
+import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
+import { bausteinHolen, bausteinVerwendungen } from "@/server/queries/bausteine";
+
+/**
+ * Ein Baustein (#030).
+ *
+ * Oben die Kopfdaten, dann der Entwurf, dann die Fassungen und zuletzt die
+ * Verwendungen. Die Reihenfolge ist die der Fragen: woran arbeite ich, was gab
+ * es vorher, und wen trifft eine Änderung.
+ */
+
+const ZEITPUNKT = new Intl.DateTimeFormat("de-DE", {
+ day: "2-digit",
+ month: "2-digit",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+});
+
+export default async function BausteinSeite({ params }: { params: Promise<{ id: string }> }) {
+ const { id } = await params;
+ const { benutzer } = await sitzungErforderlich("lesen", `/vorlagen/bausteine/${id}`);
+ const baustein = await bausteinHolen(id);
+ if (!baustein) notFound();
+
+ const darfBearbeiten = darf(benutzer.rolle, "vorlagenBearbeiten");
+ const verwendungen = await bausteinVerwendungen(id);
+ const entwurf = baustein.versionen.find((version) => version.status === VersionStatus.ENTWURF);
+ const veroeffentlichte = baustein.versionen.filter(
+ (version) => version.status === VersionStatus.VEROEFFENTLICHT,
+ );
+
+ return (
+ <>
+
+
+ Alle Bausteine
+
+
+
+
+ {baustein.name}
+
+ {baustein.archiviert ? (
+
+ Archiviert. Er lässt sich nicht mehr in einen Entwurf einsetzen, bleibt aber in jeder Vorlage
+ lesbar, die ihn schon benutzt.
+
+ >
+ )}
+
+ >
+ );
+}
diff --git a/src/app/(app)/vorlagen/bausteine/page.tsx b/src/app/(app)/vorlagen/bausteine/page.tsx
new file mode 100644
index 0000000..d3680ac
--- /dev/null
+++ b/src/app/(app)/vorlagen/bausteine/page.tsx
@@ -0,0 +1,125 @@
+import Link from "next/link";
+import { Blocks, Plus } from "lucide-react";
+import { BausteinAnlegen } from "@/components/bausteine/baustein-anlegen";
+import { darf } from "@/server/auth/rollen";
+import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
+import { bausteinListe } from "@/server/queries/bausteine";
+
+/**
+ * Die Bausteine (#030).
+ *
+ * Ein Baustein ist ein Abschnitt, den mehrere Vorlagen teilen. Er gehört zu den
+ * Vorlagen und nicht in die Verwaltung: wer Vorlagen pflegt, pflegt Bausteine.
+ *
+ * **Die Seite prüft selbst** — die Hülle darunter ist das zweite Netz, nicht das
+ * erste (siehe CLAUDE.md).
+ */
+
+export default async function BausteineSeite({
+ searchParams,
+}: {
+ searchParams: Promise>;
+}) {
+ const { benutzer } = await sitzungErforderlich("lesen", "/vorlagen/bausteine");
+ const parameter = await searchParams;
+ const mitArchivierten =
+ (Array.isArray(parameter["archiv"]) ? parameter["archiv"][0] : parameter["archiv"]) === "1";
+
+ const darfBearbeiten = darf(benutzer.rolle, "vorlagenBearbeiten");
+ const zeilen = await bausteinListe({ mitArchivierten });
+
+ return (
+ <>
+
+ Alle Vorlagen
+
+
+
+
+ Bausteine
+
+
+ Ein Abschnitt, den mehrere Vorlagen teilen. „Datensicherung prüfen“ steht in vier der sieben
+ mitgelieferten Vorlagen — als Baustein wird er an einer Stelle gepflegt.
+
+
+ Eine neue Fassung wirkt erst in der nächsten veröffentlichten Version einer Vorlage.
+ Schon veröffentlichte Versionen und laufende Arbeiten bleiben unberührt — sie tragen Kopien, und das
+ ist Absicht.
+
+
+ Anlegen und ändern dürfen Redakteure und Administratoren.
+
+ )}
+ >
+ );
+}
diff --git a/src/app/(app)/vorlagen/page.tsx b/src/app/(app)/vorlagen/page.tsx
index 228cc19..e00747d 100644
--- a/src/app/(app)/vorlagen/page.tsx
+++ b/src/app/(app)/vorlagen/page.tsx
@@ -1,5 +1,5 @@
import Link from "next/link";
-import { FileText, Pencil, Plus } from "lucide-react";
+import { Blocks, FileText, Pencil, Plus } from "lucide-react";
import { VorlageAnlegen } from "@/components/vorlage-anlegen";
import { VorlageImportieren } from "@/components/vorlage-importieren";
import { darf } from "@/server/auth/rollen";
@@ -129,7 +129,16 @@ export default async function VorlagenSeite({
return (
<>
-
Vorlagen
+
+
Vorlagen
+
+
+ Bausteine
+
+
Eine Vorlage wird veröffentlicht und ist danach unveränderlich. Ein Lauf hängt immer an der Version,
mit der er gestartet wurde — deshalb ändert eine neue Veröffentlichung nie einen laufenden oder
diff --git a/src/app/(app)/vorlagen/version/[versionId]/page.tsx b/src/app/(app)/vorlagen/version/[versionId]/page.tsx
index 9ca08fd..21dfc94 100644
--- a/src/app/(app)/vorlagen/version/[versionId]/page.tsx
+++ b/src/app/(app)/vorlagen/version/[versionId]/page.tsx
@@ -3,6 +3,7 @@ import { Editor } from "@/components/vorlagen/editor";
import { VersionStatus } from "@/generated/prisma/enums";
import { darf } from "@/server/auth/rollen";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
+import { einsetzbareBausteine } from "@/server/queries/bausteine";
import { versionMitAufbau } from "@/server/queries/vorlagen";
/**
@@ -26,6 +27,13 @@ export default async function VersionSeite({ params }: { params: Promise<{ versi
const darfBearbeiten = darf(benutzer.rolle, "vorlagenBearbeiten");
const veroeffentlicht = version.status === VersionStatus.VEROEFFENTLICHT;
+ /*
+ * Die einsetzbaren Bausteine braucht nur ein Entwurf, den jemand aendern darf
+ * (#030). Eine veroeffentlichte Version bekommt sie trotzdem — sonst stuenden
+ * bei ihren Baustein-Abschnitten statt des Namens nur „nicht mehr verfuegbar“,
+ * und das waere eine Falschaussage ueber die Herkunft.
+ */
+ const bausteine = await einsetzbareBausteine();
return (
(bausteinAnlegenAktion, {});
+
+ return (
+
+
+ {ergebnis.fehler ? (
+
+ {ergebnis.fehler}
+
+ ) : null}
+
+ );
+}
diff --git a/src/components/bausteine/baustein-editor.tsx b/src/components/bausteine/baustein-editor.tsx
new file mode 100644
index 0000000..c327101
--- /dev/null
+++ b/src/components/bausteine/baustein-editor.tsx
@@ -0,0 +1,257 @@
+"use client";
+
+import { useActionState, useMemo, useState } from "react";
+import { LoeschKnopf, TextKnopf, Textfeld } from "@/components/vorlagen/editor-felder";
+import { SortierBereich, SortierZeile } from "@/components/vorlagen/editor-sortieren";
+import { SchrittKarte } from "@/components/vorlagen/editor-schritt";
+import {
+ alsStruktur,
+ alsEditorAufbau,
+ neuerSchritt,
+ umsortieren,
+ type EditorSchritt,
+} from "@/lib/editor-aufbau";
+import { GRENZEN } from "@/lib/vorlagen-format";
+import {
+ bausteinEntwurfSpeichernAktion,
+ bausteinEntwurfVerwerfenAktion,
+ bausteinVeroeffentlichenAktion,
+ type AktionsErgebnis,
+} from "@/server/actions/bausteine";
+
+/**
+ * Titel und Schritte eines Baustein-Entwurfs (#030).
+ *
+ * Bewusst **kein** zweiter grosser Editor: ein Baustein ist ein Abschnitt, also
+ * ein Titel und eine Liste Schritte. Die Schritt-Karten und das Sortieren kommen
+ * aus dem Vorlagen-Editor — dieselben Bauteile, damit sich beides gleich anfuehlt
+ * und ein Feinschliff nur an einer Stelle geschieht.
+ *
+ * Variablen hat ein Baustein nicht. Platzhalter darf er benutzen; sie werden aus
+ * der **Vorlage** gefuellt, in der er landet. Das steht als Hinweis an der Seite,
+ * weil es sonst niemand erwartet.
+ */
+
+export type BausteinEntwurf = {
+ readonly bausteinId: string;
+ readonly versionId: string;
+ readonly version: number;
+ readonly titel: string;
+ readonly schritte: readonly {
+ readonly titel: string;
+ readonly hilfetext: string | null;
+ readonly typ: EditorSchritt["typ"];
+ readonly pflicht: boolean;
+ readonly konfig: unknown;
+ readonly dokuUrl: string | null;
+ }[];
+ /** Gibt es schon eine veröffentlichte Fassung? Dann darf ein Entwurf verworfen werden. */
+ readonly darfVerwerfen: boolean;
+};
+
+export function BausteinEditor({ entwurf }: { entwurf: BausteinEntwurf }) {
+ // Über `alsEditorAufbau` statt von Hand: so bekommen die Schritte dieselbe
+ // Aufbereitung wie im Vorlagen-Editor (leere Texte statt null, Konfig je Typ).
+ const anfang = useMemo(
+ () =>
+ alsEditorAufbau({
+ variablen: [],
+ abschnitte: [
+ {
+ titel: entwurf.titel,
+ schritte: entwurf.schritte.map((schritt) => ({
+ titel: schritt.titel,
+ ...(schritt.hilfetext === null ? {} : { hilfetext: schritt.hilfetext }),
+ typ: schritt.typ,
+ pflicht: schritt.pflicht,
+ konfig: schritt.konfig as never,
+ ...(schritt.dokuUrl === null ? {} : { dokuUrl: schritt.dokuUrl }),
+ })),
+ },
+ ],
+ }).abschnitte[0],
+ [entwurf],
+ );
+
+ const [titel, setzeTitel] = useState(anfang?.titel ?? "");
+ const [schritte, setzeSchritte] = useState(anfang?.schritte ?? []);
+ const [zuletzt, setzeZuletzt] = useState(null);
+
+ const [speichern, speichernAktion, speichertGerade] = useActionState(
+ bausteinEntwurfSpeichernAktion,
+ {},
+ );
+ const [veroeffentlichen, veroeffentlichenAktion, veroeffentlichtGerade] = useActionState<
+ AktionsErgebnis,
+ FormData
+ >(bausteinVeroeffentlichenAktion, {});
+ const [verwerfen, verwerfenAktion, verwirftGerade] = useActionState(
+ bausteinEntwurfVerwerfenAktion,
+ {},
+ );
+
+ const jetzt = JSON.stringify(
+ alsStruktur({ variablen: [], abschnitte: [{ kennung: "x", titel, schritte }] }).abschnitte[0],
+ );
+ const vorher = JSON.stringify(
+ alsStruktur({ variablen: [], abschnitte: [anfang ?? { kennung: "x", titel: "", schritte: [] }] })
+ .abschnitte[0],
+ );
+ const geaendert = jetzt !== vorher;
+ const voll = schritte.length >= GRENZEN.schritteJeAbschnitt;
+
+ return (
+
+
+
+
Entwurf, Fassung {entwurf.version}
+ {geaendert ? nicht gespeichert : null}
+
+
+
+
+
+ So heißt der Abschnitt später in jeder Vorlage, die diesen Baustein einsetzt. Platzhalter wie{" "}
+ {"{{mitarbeiter}}"} darfst du benutzen — sie werden aus der Vorlage
+ gefüllt, in der der Baustein landet.
+
+
+
+
+
+
Schritte
+ {schritte.length === 0 ? (
+
+ Noch kein Schritt. Ein Baustein ohne Schritte lässt sich nicht veröffentlichen.
+
+ {/* Kein Formular drumherum: der Knopf fragt erst nach und ruft die
+ Action dann selbst — ein zweiter Absendeweg wäre einer zu viel. */}
+ {
+ const formular = new FormData();
+ formular.set("bausteinId", entwurf.bausteinId);
+ formular.set("versionId", entwurf.versionId);
+ verwerfenAktion(formular);
+ }}
+ />
+ {verwerfen.fehler ? (
+
+ {verwerfen.fehler}
+
+ ) : null}
+
+ ) : null}
+
+
+ );
+}
diff --git a/src/components/bausteine/baustein-kopf.tsx b/src/components/bausteine/baustein-kopf.tsx
new file mode 100644
index 0000000..c62fd38
--- /dev/null
+++ b/src/components/bausteine/baustein-kopf.tsx
@@ -0,0 +1,151 @@
+"use client";
+
+import { useActionState } from "react";
+import {
+ bausteinArchivierenAktion,
+ bausteinBearbeitenAktion,
+ bausteinEntwurfSpeichernAktion,
+ type AktionsErgebnis,
+} from "@/server/actions/bausteine";
+
+/**
+ * Kopfdaten eines Bausteins und die beiden Wege daneben (#030).
+ *
+ * `nurEntwurfKnopf` ist der Fall „es gibt keinen Entwurf“: dann steht hier nur
+ * der Knopf, der einen erzeugt. Er geht ueber dieselbe Action wie das Speichern
+ * und schickt den aktuellen Stand der letzten Fassung mit — `bausteinEntwurf-
+ * HolenOderErzeugen` legt den Entwurf als tiefe Kopie an, und ein zweiter Weg
+ * dafuer waere eine zweite Regel.
+ */
+export function BausteinKopf({
+ baustein,
+ nurEntwurfKnopf = false,
+}: {
+ baustein: {
+ readonly id: string;
+ readonly name: string;
+ readonly beschreibung: string;
+ readonly archiviert: boolean;
+ };
+ nurEntwurfKnopf?: boolean;
+}) {
+ const [kopf, kopfAktion, kopfLaeuft] = useActionState(
+ bausteinBearbeitenAktion,
+ {},
+ );
+ const [archiv, archivAktion, archivLaeuft] = useActionState(
+ bausteinArchivierenAktion,
+ {},
+ );
+ const [entwurf, entwurfAktion, entwurfLaeuft] = useActionState(
+ bausteinEntwurfSpeichernAktion,
+ {},
+ );
+
+ if (nurEntwurfKnopf) {
+ return (
+
+
Ändern
+
+ Eine veröffentlichte Fassung wird nie verändert. „Bearbeiten“ erzeugt daraus einen neuen Entwurf —
+ eine tiefe Kopie, an der du arbeitest, bis du sie veröffentlichst.
+
+
+ {entwurf.fehler ? (
+
+ {entwurf.fehler}
+
+ ) : null}
+
+ );
+ }
+
+ return (
+
+
Kopfdaten
+
+
+
+
+ );
+}
diff --git a/src/components/vorlagen/editor-abschnitt.tsx b/src/components/vorlagen/editor-abschnitt.tsx
index 8af4ded..2006175 100644
--- a/src/components/vorlagen/editor-abschnitt.tsx
+++ b/src/components/vorlagen/editor-abschnitt.tsx
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
+import { Blocks } from "lucide-react";
import { KlappKarte, LoeschKnopf, TextKnopf, Textfeld } from "@/components/vorlagen/editor-felder";
import { SortierBereich, SortierZeile, VerschiebeKnoepfe } from "@/components/vorlagen/editor-sortieren";
import { SchrittKarte } from "@/components/vorlagen/editor-schritt";
@@ -57,6 +58,7 @@ export function AbschnittKarte({
const [offen, setzeOffen] = useState(true);
const voll = abschnitt.schritte.length >= GRENZEN.schritteJeAbschnitt;
const anzahlSchritte = abschnitt.schritte.length;
+ const baustein = abschnitt.baustein;
return (
{anzahlSchritte} {anzahlSchritte === 1 ? "Schritt" : "Schritte"}
+ {baustein ? (
+
+
+ Baustein
+
+ ) : null}
>
}
knoepfe={
@@ -96,7 +104,32 @@ export function AbschnittKarte({
)
}
>
- {lesend ? null : (
+ {/*
+ Ein Baustein-Abschnitt ist eine **Einheit** (#030): weder Titel noch
+ Schritte lassen sich hier aendern. Beides kommt beim Veroeffentlichen aus
+ dem Baustein, und ein Feld, das man ausfuellen kann und das danach
+ ueberschrieben wird, waere eine Falle. Geaendert wird am Baustein.
+ */}
+ {baustein ? (
+ <>
+
+ Dieser Abschnitt kommt aus dem Baustein{" "}
+ {baustein.name}
+ {baustein.version > 0 ? `, Fassung ${baustein.version}` : ""}. Titel und Schritte werden beim
+ Veröffentlichen von dort eingesetzt — geändert wird sie am Baustein, nicht hier.
+
+
+ Eine neue Fassung des Bausteins wirkt erst in der nächsten veröffentlichten
+ Version dieser Vorlage. Schon veröffentlichte Versionen und laufende Arbeiten bleiben, wie sie
+ sind.
+
+
+ {anzahlSchritte} {anzahlSchritte === 1 ? "Schritt" : "Schritte"} aus dem Baustein.
+