feat(bausteine): Abschnitte, die mehrere Vorlagen teilen #57
@@ -26,6 +26,14 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
### Hinzugefügt
|
### 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
|
- **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,
|
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,
|
brauchte `psql`. Jetzt mit Volltext über Aktion und Lauftitel, Filter nach Urheber und Zeitraum,
|
||||||
|
|||||||
@@ -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
|
Nutz sie vor dem Veröffentlichen, besonders bei Befehlen: erst hier sieht man, ob der fertige Befehl
|
||||||
so aussieht, wie er aussehen soll.
|
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
|
## Veröffentlichen
|
||||||
|
|
||||||
Im Abschnitt **Veröffentlichen** trägst du eine **Änderungsnotiz (optional)** ein — ein Satz, was
|
Im Abschnitt **Veröffentlichen** trägst du eine **Änderungsnotiz (optional)** ein — ein Satz, was
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# #030 M17 – Wiederverwendbare Bausteine
|
# #030 M17 – Wiederverwendbare Bausteine
|
||||||
|
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Im Tracker:** [#50](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/50)
|
**Im Tracker:** [#50](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/50)
|
||||||
**Meilenstein:** v0.2.0
|
**Meilenstein:** v0.2.0
|
||||||
**Labels:** phase-2, backend, frontend
|
**Labels:** phase-2, backend, frontend
|
||||||
**Geschätzt:** 12 h **Tatsächlich:** –
|
**Geschätzt:** 12 h **Tatsächlich:** 2 h 25 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ wurden. Ein Baustein, der sich ändert, darf das nicht antasten.
|
|||||||
|
|
||||||
## Zu entscheiden
|
## 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
|
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`,
|
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
|
`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-
|
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
|
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.
|
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
|
Baustein-Version wirkt erst in der **nächsten** veröffentlichten Vorlagenversion. Das muss die
|
||||||
Oberfläche sagen, sonst erwartet man Durchschlag.
|
Oberfläche sagen, sonst erwartet man Durchschlag.
|
||||||
|
|
||||||
## Aufgaben
|
## 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)
|
Entwurf, veröffentlichte Version unveränderlich (Trigger, nicht nur Dienst)
|
||||||
- [ ] `VorlagenAbschnitt` kann statt eigener Schritte einen Baustein nennen — nur im **Entwurf**
|
- [x] `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] 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
|
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
|
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)
|
Bausteine)
|
||||||
- [ ] „Wo wird dieser Baustein benutzt“ — vor dem Ändern will man das wissen
|
- [x] „Wo wird dieser Baustein benutzt“ — vor dem Ändern will man das wissen
|
||||||
- [ ] Anleitung Kapitel 04
|
- [x] Anleitung Kapitel 04
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## 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
|
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
|
Zeichen dieselbe
|
||||||
- [ ] Test: die nächste veröffentlichte Vorlagenversion enthält die neue Baustein-Version
|
- [x] 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
|
- [x] 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: 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
|
Datei trägt die aufgelösten Schritte, keinen Verweis auf einen Baustein, den die andere
|
||||||
Installation nicht hat
|
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.
|
||||||
|
|||||||
@@ -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 | – |
|
| #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 | – |
|
| #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 | – |
|
| #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 | – |
|
| #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 | – |
|
| #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 | – |
|
| #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 | – |
|
| #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 | – |
|
| #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** |
|
||||||
|
|||||||
@@ -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();
|
||||||
+76
-6
@@ -464,13 +464,83 @@ model VorlagenVariable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model VorlagenAbschnitt {
|
model VorlagenAbschnitt {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
versionId String
|
versionId String
|
||||||
titel String
|
titel String
|
||||||
position Int @default(0)
|
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)
|
version VorlagenVersion @relation(fields: [versionId], references: [id], onDelete: Cascade)
|
||||||
schritte VorlagenSchritt[]
|
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])
|
@@index([versionId, position])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
href="/vorlagen/bausteine"
|
||||||
|
className="inline-flex min-h-11 items-center gap-2 text-sm text-nebentext hover:text-text"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="size-4" strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
Alle Bausteine
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<h1 className="mt-2 flex items-center gap-2 text-2xl font-bold">
|
||||||
|
<Blocks className="size-6 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
<span className={baustein.archiviert ? "line-through" : ""}>{baustein.name}</span>
|
||||||
|
</h1>
|
||||||
|
{baustein.archiviert ? (
|
||||||
|
<p className="mt-2 max-w-prose text-sm text-faellig">
|
||||||
|
Archiviert. Er lässt sich nicht mehr in einen Entwurf einsetzen, bleibt aber in jeder Vorlage
|
||||||
|
lesbar, die ihn schon benutzt.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{darfBearbeiten ? (
|
||||||
|
<div className="mt-6">
|
||||||
|
<BausteinKopf
|
||||||
|
baustein={{
|
||||||
|
id: baustein.id,
|
||||||
|
name: baustein.name,
|
||||||
|
beschreibung: baustein.beschreibung ?? "",
|
||||||
|
archiviert: baustein.archiviert,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : baustein.beschreibung ? (
|
||||||
|
<p className="mt-4 max-w-prose text-nebentext">{baustein.beschreibung}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{darfBearbeiten && !baustein.archiviert ? (
|
||||||
|
<section className="mt-8">
|
||||||
|
<h2 className="sr-only">Entwurf</h2>
|
||||||
|
{entwurf ? (
|
||||||
|
<BausteinEditor
|
||||||
|
entwurf={{
|
||||||
|
bausteinId: baustein.id,
|
||||||
|
versionId: entwurf.id,
|
||||||
|
version: entwurf.version,
|
||||||
|
titel: entwurf.titel,
|
||||||
|
schritte: entwurf.schritte.map((schritt) => ({
|
||||||
|
titel: schritt.titel,
|
||||||
|
hilfetext: schritt.hilfetext,
|
||||||
|
typ: schritt.typ,
|
||||||
|
pflicht: schritt.pflicht,
|
||||||
|
konfig: schritt.konfig,
|
||||||
|
dokuUrl: schritt.dokuUrl,
|
||||||
|
})),
|
||||||
|
darfVerwerfen: veroeffentlichte.length > 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
/* Kein Entwurf: der Knopf erzeugt ihn als tiefe Kopie der letzten
|
||||||
|
veröffentlichten Fassung — dieselbe Regel wie bei Vorlagen. */
|
||||||
|
<BausteinKopf
|
||||||
|
baustein={{
|
||||||
|
id: baustein.id,
|
||||||
|
name: baustein.name,
|
||||||
|
beschreibung: baustein.beschreibung ?? "",
|
||||||
|
archiviert: baustein.archiviert,
|
||||||
|
}}
|
||||||
|
nurEntwurfKnopf
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<section className="mt-8 rounded-karte border border-linie bg-flaeche p-5">
|
||||||
|
<h2 className="text-lg font-semibold">Fassungen</h2>
|
||||||
|
{veroeffentlichte.length === 0 ? (
|
||||||
|
<p className="mt-2 text-sm text-nebentext">Noch keine veröffentlichte Fassung.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="mt-3 flex flex-col gap-2">
|
||||||
|
{veroeffentlichte.map((version) => (
|
||||||
|
<li
|
||||||
|
key={version.id}
|
||||||
|
className="flex flex-wrap items-baseline gap-x-3 gap-y-1 border-t border-linie py-2 text-sm first:border-t-0"
|
||||||
|
>
|
||||||
|
<span className="zahl shrink-0 text-akzent">{String(version.version).padStart(2, "0")}</span>
|
||||||
|
<span className="min-w-0 break-words">{version.titel}</span>
|
||||||
|
<span className="text-nebentext">
|
||||||
|
{version.schritte.length} {version.schritte.length === 1 ? "Schritt" : "Schritte"}
|
||||||
|
</span>
|
||||||
|
{version.veroeffentlichtAm ? (
|
||||||
|
<span className="mono text-xs text-nebentext">
|
||||||
|
{ZEITPUNKT.format(version.veroeffentlichtAm)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{version.aenderungsnotiz ? (
|
||||||
|
<span className="min-w-0 break-words text-nebentext">· {version.aenderungsnotiz}</span>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-4 rounded-karte border border-linie bg-flaeche p-5">
|
||||||
|
<h2 className="text-lg font-semibold">Wo er steckt</h2>
|
||||||
|
{verwendungen.length === 0 ? (
|
||||||
|
<p className="mt-2 text-sm text-nebentext">
|
||||||
|
In keiner Vorlagenversion. Eine Änderung trifft damit nichts Bestehendes.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="mt-1 max-w-prose text-sm text-nebentext">
|
||||||
|
Jede Zeile nennt die Fassung, die dort <strong>eingesetzt</strong> ist. Eine neue Fassung ändert
|
||||||
|
davon nichts.
|
||||||
|
</p>
|
||||||
|
<ul className="mt-3 flex flex-col gap-2">
|
||||||
|
{verwendungen.map((eintrag, stelle) => (
|
||||||
|
<li
|
||||||
|
key={`${eintrag.vorlageId}-${eintrag.vorlagenVersion}-${stelle}`}
|
||||||
|
className="flex flex-wrap items-baseline gap-x-3 gap-y-1 border-t border-linie py-2 text-sm first:border-t-0"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={`/vorlagen/${eintrag.vorlageId}`}
|
||||||
|
className={`min-w-0 break-words text-akzent underline underline-offset-2 ${
|
||||||
|
eintrag.archiviert ? "line-through" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{eintrag.vorlageName}
|
||||||
|
</Link>
|
||||||
|
<span className="text-nebentext">Version {eintrag.vorlagenVersion}</span>
|
||||||
|
<span className="rounded-pille bg-flaeche-hoch px-2.5 py-0.5 text-xs text-nebentext">
|
||||||
|
Fassung {eintrag.bausteinVersion}
|
||||||
|
</span>
|
||||||
|
{eintrag.status === VersionStatus.ENTWURF ? (
|
||||||
|
<span className="text-xs text-faellig">Entwurf</span>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<Record<string, string | string[] | undefined>>;
|
||||||
|
}) {
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
href="/vorlagen"
|
||||||
|
className="inline-flex min-h-11 items-center text-sm text-nebentext hover:text-text"
|
||||||
|
>
|
||||||
|
Alle Vorlagen
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<h1 className="mt-2 flex items-center gap-2 text-2xl font-bold">
|
||||||
|
<Blocks className="size-6 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
Bausteine
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 max-w-prose text-nebentext">
|
||||||
|
Ein Abschnitt, den mehrere Vorlagen teilen. „Datensicherung prüfen“ steht in vier der sieben
|
||||||
|
mitgelieferten Vorlagen — als Baustein wird er an einer Stelle gepflegt.
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 max-w-prose text-sm text-nebentext">
|
||||||
|
Eine neue Fassung wirkt erst in der <strong>nächsten</strong> veröffentlichten Version einer Vorlage.
|
||||||
|
Schon veröffentlichte Versionen und laufende Arbeiten bleiben unberührt — sie tragen Kopien, und das
|
||||||
|
ist Absicht.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{darfBearbeiten ? (
|
||||||
|
<div className="mt-6">
|
||||||
|
<BausteinAnlegen />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="mt-6 flex flex-wrap items-center gap-3">
|
||||||
|
<Link
|
||||||
|
href={mitArchivierten ? "/vorlagen/bausteine" : "/vorlagen/bausteine?archiv=1"}
|
||||||
|
className="flex min-h-11 items-center rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10"
|
||||||
|
>
|
||||||
|
{mitArchivierten ? "Archivierte ausblenden" : "Archivierte mitzeigen"}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{zeilen.length === 0 ? (
|
||||||
|
<p className="mt-6 rounded-karte border border-linie bg-flaeche p-5 text-sm text-nebentext">
|
||||||
|
Noch kein Baustein.{" "}
|
||||||
|
{darfBearbeiten
|
||||||
|
? "Lege einen an — zum Beispiel für die Schritte, die in mehreren Vorlagen gleich sind."
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="mt-4 flex flex-col gap-2">
|
||||||
|
{zeilen.map((zeile) => (
|
||||||
|
<li key={zeile.id} className="rounded-karte border border-linie bg-flaeche p-4">
|
||||||
|
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||||
|
<Link
|
||||||
|
href={`/vorlagen/bausteine/${zeile.id}`}
|
||||||
|
className={`min-w-0 font-semibold break-words text-akzent underline underline-offset-2 ${
|
||||||
|
zeile.archiviert ? "line-through" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{zeile.name}
|
||||||
|
</Link>
|
||||||
|
{zeile.veroeffentlichteVersion === null ? (
|
||||||
|
<span className="rounded-pille bg-flaeche-hoch px-2.5 py-0.5 text-xs text-nebentext">
|
||||||
|
noch nicht veröffentlicht
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-nebentext">Fassung {zeile.veroeffentlichteVersion}</span>
|
||||||
|
)}
|
||||||
|
{zeile.hatEntwurf ? (
|
||||||
|
<span className="rounded-pille bg-akzent-flaeche px-2.5 py-0.5 text-xs font-semibold text-akzent">
|
||||||
|
Entwurf
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{zeile.archiviert ? <span className="text-xs text-nebentext">archiviert</span> : null}
|
||||||
|
</div>
|
||||||
|
{zeile.beschreibung ? (
|
||||||
|
<p className="mt-1 max-w-prose text-sm text-nebentext">{zeile.beschreibung}</p>
|
||||||
|
) : null}
|
||||||
|
<p className="mt-1 text-sm text-nebentext">
|
||||||
|
{zeile.anzahlSchritte} {zeile.anzahlSchritte === 1 ? "Schritt" : "Schritte"} ·{" "}
|
||||||
|
{zeile.verwendungen === 0
|
||||||
|
? "in keiner Vorlage"
|
||||||
|
: `in ${zeile.verwendungen} ${zeile.verwendungen === 1 ? "Vorlagenversion" : "Vorlagenversionen"}`}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{darfBearbeiten ? null : (
|
||||||
|
<p className="mt-6 flex items-center gap-2 text-sm text-nebentext">
|
||||||
|
<Plus className="size-4" strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
Anlegen und ändern dürfen Redakteure und Administratoren.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import Link from "next/link";
|
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 { VorlageAnlegen } from "@/components/vorlage-anlegen";
|
||||||
import { VorlageImportieren } from "@/components/vorlage-importieren";
|
import { VorlageImportieren } from "@/components/vorlage-importieren";
|
||||||
import { darf } from "@/server/auth/rollen";
|
import { darf } from "@/server/auth/rollen";
|
||||||
@@ -129,7 +129,16 @@ export default async function VorlagenSeite({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h1 className="text-2xl font-bold">Vorlagen</h1>
|
<div className="flex flex-wrap items-baseline justify-between gap-3">
|
||||||
|
<h1 className="text-2xl font-bold">Vorlagen</h1>
|
||||||
|
<Link
|
||||||
|
href="/vorlagen/bausteine"
|
||||||
|
className="flex min-h-11 items-center gap-2 rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10"
|
||||||
|
>
|
||||||
|
<Blocks className="size-4" strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
Bausteine
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
<p className="mt-2 max-w-prose text-nebentext">
|
<p className="mt-2 max-w-prose text-nebentext">
|
||||||
Eine Vorlage wird veröffentlicht und ist danach unveränderlich. Ein Lauf hängt immer an der Version,
|
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
|
mit der er gestartet wurde — deshalb ändert eine neue Veröffentlichung nie einen laufenden oder
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Editor } from "@/components/vorlagen/editor";
|
|||||||
import { VersionStatus } from "@/generated/prisma/enums";
|
import { VersionStatus } from "@/generated/prisma/enums";
|
||||||
import { darf } from "@/server/auth/rollen";
|
import { darf } from "@/server/auth/rollen";
|
||||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { einsetzbareBausteine } from "@/server/queries/bausteine";
|
||||||
import { versionMitAufbau } from "@/server/queries/vorlagen";
|
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 darfBearbeiten = darf(benutzer.rolle, "vorlagenBearbeiten");
|
||||||
const veroeffentlicht = version.status === VersionStatus.VEROEFFENTLICHT;
|
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 (
|
return (
|
||||||
<Editor
|
<Editor
|
||||||
@@ -41,6 +49,7 @@ export default async function VersionSeite({ params }: { params: Promise<{ versi
|
|||||||
}}
|
}}
|
||||||
lesend={veroeffentlicht || !darfBearbeiten}
|
lesend={veroeffentlicht || !darfBearbeiten}
|
||||||
darfBearbeiten={darfBearbeiten}
|
darfBearbeiten={darfBearbeiten}
|
||||||
|
bausteine={bausteine}
|
||||||
// Auf dem Server formatiert: im Browser waere es eine zweite Uhrzeit und
|
// Auf dem Server formatiert: im Browser waere es eine zweite Uhrzeit und
|
||||||
// damit ein Unterschied zwischen Vorrendern und Hydrieren.
|
// damit ein Unterschied zwischen Vorrendern und Hydrieren.
|
||||||
beispielDatum={new Date().toLocaleDateString("de-DE", {
|
beispielDatum={new Date().toLocaleDateString("de-DE", {
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState } from "react";
|
||||||
|
import { bausteinAnlegenAktion, type AktionsErgebnis } from "@/server/actions/bausteine";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einen Baustein anlegen (#030).
|
||||||
|
*
|
||||||
|
* Nur der Name. Titel und Schritte kommen danach im Entwurf — ein Formular, das
|
||||||
|
* alles auf einmal fragt, stünde dem Anlegen im Weg.
|
||||||
|
*/
|
||||||
|
export function BausteinAnlegen() {
|
||||||
|
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(bausteinAnlegenAktion, {});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<form action={aktion} className="flex flex-wrap items-end gap-3">
|
||||||
|
<label className="flex min-w-0 flex-1 basis-64 flex-col gap-1.5 text-sm">
|
||||||
|
<span className="text-nebentext">Name des Bausteins</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
placeholder="Datensicherung prüfen"
|
||||||
|
className="min-h-11 rounded-karte border border-linie bg-grund px-3 text-sm outline-none focus-visible:border-akzent md:min-h-10"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={laeuft}
|
||||||
|
className="min-h-11 rounded-pille bg-akzent px-4 text-sm font-semibold text-grund md:min-h-10"
|
||||||
|
>
|
||||||
|
Anlegen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{ergebnis.fehler ? (
|
||||||
|
<p role="alert" className="mt-2 max-w-prose text-sm text-fehler">
|
||||||
|
{ergebnis.fehler}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<readonly EditorSchritt[]>(anfang?.schritte ?? []);
|
||||||
|
const [zuletzt, setzeZuletzt] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [speichern, speichernAktion, speichertGerade] = useActionState<AktionsErgebnis, FormData>(
|
||||||
|
bausteinEntwurfSpeichernAktion,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const [veroeffentlichen, veroeffentlichenAktion, veroeffentlichtGerade] = useActionState<
|
||||||
|
AktionsErgebnis,
|
||||||
|
FormData
|
||||||
|
>(bausteinVeroeffentlichenAktion, {});
|
||||||
|
const [verwerfen, verwerfenAktion, verwirftGerade] = useActionState<AktionsErgebnis, FormData>(
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<div className="rounded-karte border border-linie bg-flaeche p-5">
|
||||||
|
<div className="flex flex-wrap items-baseline justify-between gap-3">
|
||||||
|
<h2 className="text-lg font-semibold">Entwurf, Fassung {entwurf.version}</h2>
|
||||||
|
{geaendert ? <span className="text-sm text-faellig">nicht gespeichert</span> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-col gap-4">
|
||||||
|
<Textfeld
|
||||||
|
beschriftung="Titel des Abschnitts"
|
||||||
|
wert={titel}
|
||||||
|
setzen={setzeTitel}
|
||||||
|
lesend={false}
|
||||||
|
vorschlag="Datensicherung"
|
||||||
|
/>
|
||||||
|
<p className="max-w-prose text-sm text-nebentext">
|
||||||
|
So heißt der Abschnitt später in jeder Vorlage, die diesen Baustein einsetzt. Platzhalter wie{" "}
|
||||||
|
<code className="mono">{"{{mitarbeiter}}"}</code> darfst du benutzen — sie werden aus der Vorlage
|
||||||
|
gefüllt, in der der Baustein landet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-karte border border-linie bg-flaeche p-5">
|
||||||
|
<h2 className="text-lg font-semibold">Schritte</h2>
|
||||||
|
{schritte.length === 0 ? (
|
||||||
|
<p className="mt-2 text-sm text-nebentext">
|
||||||
|
Noch kein Schritt. Ein Baustein ohne Schritte lässt sich nicht veröffentlichen.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<SortierBereich
|
||||||
|
kennungen={schritte.map((schritt) => schritt.kennung)}
|
||||||
|
art="Schritt"
|
||||||
|
lesend={false}
|
||||||
|
aufSortiert={(von, nach) => setzeSchritte(umsortieren(schritte, von, nach))}
|
||||||
|
>
|
||||||
|
<ul className="mt-4 flex flex-col gap-3">
|
||||||
|
{schritte.map((schritt, stelle) => (
|
||||||
|
<SortierZeile key={schritt.kennung} kennung={schritt.kennung} lesend={false}>
|
||||||
|
{(anfasser) => (
|
||||||
|
<SchrittKarte
|
||||||
|
schritt={schritt}
|
||||||
|
index={stelle}
|
||||||
|
anzahl={schritte.length}
|
||||||
|
lesend={false}
|
||||||
|
neu={schritt.kennung === zuletzt}
|
||||||
|
anfasser={anfasser}
|
||||||
|
aendern={(teil) =>
|
||||||
|
setzeSchritte(
|
||||||
|
schritte.map((eintrag) =>
|
||||||
|
eintrag.kennung === schritt.kennung ? { ...eintrag, ...teil } : eintrag,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
loeschen={() =>
|
||||||
|
setzeSchritte(schritte.filter((eintrag) => eintrag.kennung !== schritt.kennung))
|
||||||
|
}
|
||||||
|
aufSortiert={(von, nach) => setzeSchritte(umsortieren(schritte, von, nach))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SortierZeile>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</SortierBereich>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<TextKnopf
|
||||||
|
beschriftung={voll ? `Höchstens ${GRENZEN.schritteJeAbschnitt} Schritte` : "Schritt hinzufügen"}
|
||||||
|
aus={voll}
|
||||||
|
aufKlick={() => {
|
||||||
|
const schritt = neuerSchritt();
|
||||||
|
setzeZuletzt(schritt.kennung);
|
||||||
|
setzeSchritte([...schritte, schritt]);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 rounded-karte border border-linie bg-flaeche p-5">
|
||||||
|
<form action={speichernAktion} className="flex flex-wrap items-center gap-3">
|
||||||
|
<input type="hidden" name="bausteinId" value={entwurf.bausteinId} />
|
||||||
|
<input type="hidden" name="abschnitt" value={jetzt} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={speichertGerade}
|
||||||
|
className="min-h-11 rounded-pille bg-akzent px-4 text-sm font-semibold text-grund md:min-h-10"
|
||||||
|
>
|
||||||
|
Entwurf speichern
|
||||||
|
</button>
|
||||||
|
{speichern.erfolg && !geaendert ? (
|
||||||
|
<span role="status" className="text-sm text-erledigt">
|
||||||
|
{speichern.erfolg}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{speichern.fehler ? (
|
||||||
|
<span role="alert" className="text-sm text-fehler">
|
||||||
|
{speichern.fehler}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form action={veroeffentlichenAktion} className="flex flex-wrap items-end gap-3">
|
||||||
|
<input type="hidden" name="bausteinId" value={entwurf.bausteinId} />
|
||||||
|
<input type="hidden" name="versionId" value={entwurf.versionId} />
|
||||||
|
<label className="flex min-w-0 flex-1 basis-64 flex-col gap-1.5 text-sm">
|
||||||
|
<span className="text-nebentext">Änderungsnotiz (optional)</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="aenderungsnotiz"
|
||||||
|
placeholder="Was hat sich geändert?"
|
||||||
|
className="min-h-11 rounded-karte border border-linie bg-grund px-3 text-sm outline-none focus-visible:border-akzent md:min-h-10"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={veroeffentlichtGerade || geaendert}
|
||||||
|
title={geaendert ? "Erst den Entwurf speichern" : undefined}
|
||||||
|
className="min-h-11 rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text disabled:opacity-40 md:min-h-10"
|
||||||
|
>
|
||||||
|
Veröffentlichen
|
||||||
|
</button>
|
||||||
|
{veroeffentlichen.fehler ? (
|
||||||
|
<span role="alert" className="text-sm text-fehler">
|
||||||
|
{veroeffentlichen.fehler}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{entwurf.darfVerwerfen ? (
|
||||||
|
<div>
|
||||||
|
{/* Kein Formular drumherum: der Knopf fragt erst nach und ruft die
|
||||||
|
Action dann selbst — ein zweiter Absendeweg wäre einer zu viel. */}
|
||||||
|
<LoeschKnopf
|
||||||
|
beschriftung={verwirftGerade ? "wird verworfen …" : "Entwurf verwerfen"}
|
||||||
|
frage="Entwurf verwerfen? Die veröffentlichte Fassung bleibt."
|
||||||
|
aufLoeschen={() => {
|
||||||
|
const formular = new FormData();
|
||||||
|
formular.set("bausteinId", entwurf.bausteinId);
|
||||||
|
formular.set("versionId", entwurf.versionId);
|
||||||
|
verwerfenAktion(formular);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{verwerfen.fehler ? (
|
||||||
|
<p role="alert" className="mt-2 text-sm text-fehler">
|
||||||
|
{verwerfen.fehler}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<AktionsErgebnis, FormData>(
|
||||||
|
bausteinBearbeitenAktion,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const [archiv, archivAktion, archivLaeuft] = useActionState<AktionsErgebnis, FormData>(
|
||||||
|
bausteinArchivierenAktion,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const [entwurf, entwurfAktion, entwurfLaeuft] = useActionState<AktionsErgebnis, FormData>(
|
||||||
|
bausteinEntwurfSpeichernAktion,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (nurEntwurfKnopf) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-karte border border-linie bg-flaeche p-5">
|
||||||
|
<h2 className="text-lg font-semibold">Ändern</h2>
|
||||||
|
<p className="mt-1 max-w-prose text-sm text-nebentext">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<form action={entwurfAktion} className="mt-4">
|
||||||
|
<input type="hidden" name="bausteinId" value={baustein.id} />
|
||||||
|
{/* Leerer Aufbau: der Dienst legt den Entwurf als Kopie der letzten
|
||||||
|
Fassung an, bevor er speichert. Das Schema verlangt einen Titel. */}
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="abschnitt"
|
||||||
|
value={JSON.stringify({ titel: baustein.name, schritte: [] })}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={entwurfLaeuft}
|
||||||
|
className="min-h-11 rounded-pille bg-akzent px-4 text-sm font-semibold text-grund md:min-h-10"
|
||||||
|
>
|
||||||
|
Entwurf anlegen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{entwurf.fehler ? (
|
||||||
|
<p role="alert" className="mt-2 max-w-prose text-sm text-fehler">
|
||||||
|
{entwurf.fehler}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-karte border border-linie bg-flaeche p-5">
|
||||||
|
<h2 className="text-lg font-semibold">Kopfdaten</h2>
|
||||||
|
<form action={kopfAktion} className="mt-4 flex flex-col gap-4">
|
||||||
|
<input type="hidden" name="bausteinId" value={baustein.id} />
|
||||||
|
<label className="flex flex-col gap-1.5 text-sm">
|
||||||
|
<span className="text-nebentext">Name</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
defaultValue={baustein.name}
|
||||||
|
required
|
||||||
|
className="min-h-11 rounded-karte border border-linie bg-grund px-3 text-sm outline-none focus-visible:border-akzent md:min-h-10"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1.5 text-sm">
|
||||||
|
<span className="text-nebentext">Beschreibung (optional)</span>
|
||||||
|
<textarea
|
||||||
|
name="beschreibung"
|
||||||
|
defaultValue={baustein.beschreibung}
|
||||||
|
rows={2}
|
||||||
|
placeholder="Wofür ist dieser Baustein da?"
|
||||||
|
className="rounded-karte border border-linie bg-grund px-3 py-2 text-sm outline-none focus-visible:border-akzent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={kopfLaeuft}
|
||||||
|
className="min-h-11 rounded-pille bg-akzent px-4 text-sm font-semibold text-grund md:min-h-10"
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
{kopf.erfolg ? (
|
||||||
|
<span role="status" className="text-sm text-erledigt">
|
||||||
|
{kopf.erfolg}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{kopf.fehler ? (
|
||||||
|
<span role="alert" className="text-sm text-fehler">
|
||||||
|
{kopf.fehler}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form
|
||||||
|
action={archivAktion}
|
||||||
|
className="mt-5 flex flex-wrap items-center gap-3 border-t border-linie pt-4"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="bausteinId" value={baustein.id} />
|
||||||
|
<input type="hidden" name="archiviert" value={baustein.archiviert ? "false" : "true"} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={archivLaeuft}
|
||||||
|
className="min-h-11 rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10"
|
||||||
|
>
|
||||||
|
{baustein.archiviert ? "wieder freigeben" : "archivieren"}
|
||||||
|
</button>
|
||||||
|
<span className="max-w-prose text-sm text-nebentext">
|
||||||
|
{baustein.archiviert
|
||||||
|
? "Danach lässt er sich wieder in Entwürfe einsetzen."
|
||||||
|
: "Gelöscht wird nie: veröffentlichte Vorlagenversionen tragen seine Herkunft."}
|
||||||
|
</span>
|
||||||
|
{archiv.fehler ? (
|
||||||
|
<span role="alert" className="max-w-prose text-sm text-fehler">
|
||||||
|
{archiv.fehler}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { Blocks } from "lucide-react";
|
||||||
import { KlappKarte, LoeschKnopf, TextKnopf, Textfeld } from "@/components/vorlagen/editor-felder";
|
import { KlappKarte, LoeschKnopf, TextKnopf, Textfeld } from "@/components/vorlagen/editor-felder";
|
||||||
import { SortierBereich, SortierZeile, VerschiebeKnoepfe } from "@/components/vorlagen/editor-sortieren";
|
import { SortierBereich, SortierZeile, VerschiebeKnoepfe } from "@/components/vorlagen/editor-sortieren";
|
||||||
import { SchrittKarte } from "@/components/vorlagen/editor-schritt";
|
import { SchrittKarte } from "@/components/vorlagen/editor-schritt";
|
||||||
@@ -57,6 +58,7 @@ export function AbschnittKarte({
|
|||||||
const [offen, setzeOffen] = useState(true);
|
const [offen, setzeOffen] = useState(true);
|
||||||
const voll = abschnitt.schritte.length >= GRENZEN.schritteJeAbschnitt;
|
const voll = abschnitt.schritte.length >= GRENZEN.schritteJeAbschnitt;
|
||||||
const anzahlSchritte = abschnitt.schritte.length;
|
const anzahlSchritte = abschnitt.schritte.length;
|
||||||
|
const baustein = abschnitt.baustein;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KlappKarte
|
<KlappKarte
|
||||||
@@ -75,6 +77,12 @@ export function AbschnittKarte({
|
|||||||
<span className="shrink-0 text-xs font-normal text-nebentext">
|
<span className="shrink-0 text-xs font-normal text-nebentext">
|
||||||
{anzahlSchritte} {anzahlSchritte === 1 ? "Schritt" : "Schritte"}
|
{anzahlSchritte} {anzahlSchritte === 1 ? "Schritt" : "Schritte"}
|
||||||
</span>
|
</span>
|
||||||
|
{baustein ? (
|
||||||
|
<span className="inline-flex shrink-0 items-center gap-1 rounded-pille bg-flaeche-hoch px-2.5 py-0.5 text-xs font-normal text-nebentext">
|
||||||
|
<Blocks className="size-3.5" strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
Baustein
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
knoepfe={
|
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 ? (
|
||||||
|
<>
|
||||||
|
<p className="max-w-prose text-sm text-nebentext">
|
||||||
|
Dieser Abschnitt kommt aus dem Baustein{" "}
|
||||||
|
<span className="font-semibold text-text">{baustein.name}</span>
|
||||||
|
{baustein.version > 0 ? `, Fassung ${baustein.version}` : ""}. Titel und Schritte werden beim
|
||||||
|
Veröffentlichen von dort eingesetzt — geändert wird sie am Baustein, nicht hier.
|
||||||
|
</p>
|
||||||
|
<p className="max-w-prose text-sm text-nebentext">
|
||||||
|
Eine neue Fassung des Bausteins wirkt erst in der <strong>nächsten</strong> veröffentlichten
|
||||||
|
Version dieser Vorlage. Schon veröffentlichte Versionen und laufende Arbeiten bleiben, wie sie
|
||||||
|
sind.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-nebentext">
|
||||||
|
{anzahlSchritte} {anzahlSchritte === 1 ? "Schritt" : "Schritte"} aus dem Baustein.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!baustein && !lesend ? (
|
||||||
<Textfeld
|
<Textfeld
|
||||||
beschriftung="Titel des Abschnitts"
|
beschriftung="Titel des Abschnitts"
|
||||||
wert={abschnitt.titel}
|
wert={abschnitt.titel}
|
||||||
@@ -104,9 +137,9 @@ export function AbschnittKarte({
|
|||||||
lesend={lesend}
|
lesend={lesend}
|
||||||
vorschlag="Vorbereitung"
|
vorschlag="Vorbereitung"
|
||||||
/>
|
/>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
{anzahlSchritte === 0 ? (
|
{baustein ? null : anzahlSchritte === 0 ? (
|
||||||
<p className="text-sm text-nebentext">Noch kein Schritt in diesem Abschnitt.</p>
|
<p className="text-sm text-nebentext">Noch kein Schritt in diesem Abschnitt.</p>
|
||||||
) : (
|
) : (
|
||||||
<SortierBereich
|
<SortierBereich
|
||||||
@@ -137,7 +170,7 @@ export function AbschnittKarte({
|
|||||||
</SortierBereich>
|
</SortierBereich>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{lesend ? null : (
|
{baustein || lesend ? null : (
|
||||||
<TextKnopf
|
<TextKnopf
|
||||||
beschriftung={voll ? `Höchstens ${GRENZEN.schritteJeAbschnitt} Schritte` : "Schritt hinzufügen"}
|
beschriftung={voll ? `Höchstens ${GRENZEN.schritteJeAbschnitt} Schritte` : "Schritt hinzufügen"}
|
||||||
aus={voll}
|
aus={voll}
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ import { Vorschau } from "@/components/vorlagen/vorschau";
|
|||||||
import {
|
import {
|
||||||
alsEditorAufbau,
|
alsEditorAufbau,
|
||||||
alsStruktur,
|
alsStruktur,
|
||||||
|
bausteineAnreichern,
|
||||||
neuerAbschnitt,
|
neuerAbschnitt,
|
||||||
|
neuerBausteinAbschnitt,
|
||||||
neuerSchritt,
|
neuerSchritt,
|
||||||
neueVariable,
|
neueVariable,
|
||||||
umsortieren,
|
umsortieren,
|
||||||
@@ -67,20 +69,33 @@ type SpeicherZustand = {
|
|||||||
readonly gespeichertesJson: string;
|
readonly gespeichertesJson: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type EinsetzbarerBaustein = {
|
||||||
|
readonly bausteinVersionId: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly titel: string;
|
||||||
|
readonly version: number;
|
||||||
|
readonly anzahlSchritte: number;
|
||||||
|
};
|
||||||
|
|
||||||
export function Editor({
|
export function Editor({
|
||||||
version,
|
version,
|
||||||
lesend,
|
lesend,
|
||||||
darfBearbeiten,
|
darfBearbeiten,
|
||||||
beispielDatum,
|
beispielDatum,
|
||||||
|
bausteine = [],
|
||||||
}: {
|
}: {
|
||||||
version: EditorVersion;
|
version: EditorVersion;
|
||||||
lesend: boolean;
|
lesend: boolean;
|
||||||
darfBearbeiten: boolean;
|
darfBearbeiten: boolean;
|
||||||
/** Heutiges Datum, auf dem Server formatiert — siehe `beispielwerte`. */
|
/** Heutiges Datum, auf dem Server formatiert — siehe `beispielwerte`. */
|
||||||
beispielDatum: string;
|
beispielDatum: string;
|
||||||
|
/** Veröffentlichte Bausteine, die sich einsetzen lassen (#030). */
|
||||||
|
bausteine?: readonly EinsetzbarerBaustein[];
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [aufbau, setzeAufbau] = useState<EditorAufbau>(() => alsEditorAufbau(version.struktur));
|
const [aufbau, setzeAufbau] = useState<EditorAufbau>(() =>
|
||||||
|
bausteineAnreichern(alsEditorAufbau(version.struktur), bausteine),
|
||||||
|
);
|
||||||
const aktuellesJson = useMemo(() => JSON.stringify(alsStruktur(aufbau)), [aufbau]);
|
const aktuellesJson = useMemo(() => JSON.stringify(alsStruktur(aufbau)), [aufbau]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -92,8 +107,8 @@ export function Editor({
|
|||||||
* Speichern in die Datenbank.
|
* Speichern in die Datenbank.
|
||||||
*/
|
*/
|
||||||
const anfangsJson = useMemo(
|
const anfangsJson = useMemo(
|
||||||
() => JSON.stringify(alsStruktur(alsEditorAufbau(version.struktur))),
|
() => JSON.stringify(alsStruktur(bausteineAnreichern(alsEditorAufbau(version.struktur), bausteine))),
|
||||||
[version.struktur],
|
[version.struktur, bausteine],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -359,17 +374,52 @@ export function Editor({
|
|||||||
</SortierBereich>
|
</SortierBereich>
|
||||||
)}
|
)}
|
||||||
{lesend ? null : (
|
{lesend ? null : (
|
||||||
<TextKnopf
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
beschriftung={
|
<TextKnopf
|
||||||
abschnitteVoll ? `Höchstens ${GRENZEN.abschnitte} Abschnitte` : "Abschnitt hinzufügen"
|
beschriftung={
|
||||||
}
|
abschnitteVoll ? `Höchstens ${GRENZEN.abschnitte} Abschnitte` : "Abschnitt hinzufügen"
|
||||||
aus={abschnitteVoll}
|
}
|
||||||
aufKlick={() => {
|
aus={abschnitteVoll}
|
||||||
const abschnitt = neuerAbschnitt();
|
aufKlick={() => {
|
||||||
setzeZuletztAngelegt(abschnitt.kennung);
|
const abschnitt = neuerAbschnitt();
|
||||||
abschnitteSetzen([...aufbau.abschnitte, abschnitt]);
|
setzeZuletztAngelegt(abschnitt.kennung);
|
||||||
}}
|
abschnitteSetzen([...aufbau.abschnitte, abschnitt]);
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
{/*
|
||||||
|
Bausteine nur anbieten, wenn es welche gibt. Ein leeres
|
||||||
|
Auswahlfeld mit „keine Bausteine“ wäre eine Frage ohne Antwort
|
||||||
|
(#030).
|
||||||
|
*/}
|
||||||
|
{bausteine.length > 0 ? (
|
||||||
|
<label className="flex min-w-0 items-center gap-2 text-sm">
|
||||||
|
<span className="shrink-0 text-nebentext">Baustein einsetzen</span>
|
||||||
|
<select
|
||||||
|
aria-label="Baustein einsetzen"
|
||||||
|
value=""
|
||||||
|
disabled={abschnitteVoll}
|
||||||
|
onChange={(ereignis) => {
|
||||||
|
const gewaehlt = bausteine.find(
|
||||||
|
(eintrag) => eintrag.bausteinVersionId === ereignis.target.value,
|
||||||
|
);
|
||||||
|
if (!gewaehlt) return;
|
||||||
|
const abschnitt = neuerBausteinAbschnitt(gewaehlt);
|
||||||
|
setzeZuletztAngelegt(abschnitt.kennung);
|
||||||
|
abschnitteSetzen([...aufbau.abschnitte, abschnitt]);
|
||||||
|
}}
|
||||||
|
className="min-h-11 min-w-0 rounded-pille border border-linie bg-flaeche-hoch px-4 text-sm md:min-h-10"
|
||||||
|
>
|
||||||
|
<option value="">auswählen …</option>
|
||||||
|
{bausteine.map((eintrag) => (
|
||||||
|
<option key={eintrag.bausteinVersionId} value={eintrag.bausteinVersionId}>
|
||||||
|
{eintrag.name} (Fassung {eintrag.version}, {eintrag.anzahlSchritte}{" "}
|
||||||
|
{eintrag.anzahlSchritte === 1 ? "Schritt" : "Schritte"})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,18 @@ export type EditorAbschnitt = {
|
|||||||
readonly kennung: string;
|
readonly kennung: string;
|
||||||
readonly titel: string;
|
readonly titel: string;
|
||||||
readonly schritte: readonly EditorSchritt[];
|
readonly schritte: readonly EditorSchritt[];
|
||||||
|
/**
|
||||||
|
* Gesetzt, wenn dieser Abschnitt ein eingesetzter **Baustein** ist (#030).
|
||||||
|
*
|
||||||
|
* Dann sind `titel` und `schritte` nur Anzeige: beim Veroeffentlichen setzt
|
||||||
|
* der Dienst die Schritte des Bausteins ein. Bearbeiten laesst sich ein
|
||||||
|
* solcher Abschnitt hier nicht — das geschieht am Baustein.
|
||||||
|
*/
|
||||||
|
readonly baustein?: {
|
||||||
|
readonly versionId: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly version: number;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type EditorVariable = {
|
export type EditorVariable = {
|
||||||
@@ -207,6 +219,63 @@ export function neuerAbschnitt(): EditorAbschnitt {
|
|||||||
return { kennung: neueKennung("abschnitt"), titel: "", schritte: [] };
|
return { kennung: neueKennung("abschnitt"), titel: "", schritte: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Abschnitt, der einen Baustein einsetzt (#030).
|
||||||
|
*
|
||||||
|
* Titel und Schritte kommen zur Anzeige mit; gespeichert wird nur der Verweis,
|
||||||
|
* und beim Veroeffentlichen setzt der Dienst die Schritte des Bausteins ein.
|
||||||
|
*/
|
||||||
|
export function neuerBausteinAbschnitt(baustein: {
|
||||||
|
readonly bausteinVersionId: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly titel: string;
|
||||||
|
readonly version: number;
|
||||||
|
readonly anzahlSchritte: number;
|
||||||
|
}): EditorAbschnitt {
|
||||||
|
return {
|
||||||
|
kennung: neueKennung("abschnitt"),
|
||||||
|
titel: baustein.titel,
|
||||||
|
baustein: { versionId: baustein.bausteinVersionId, name: baustein.name, version: baustein.version },
|
||||||
|
// Platzhalter, damit die Anzahl stimmt. Gespeichert werden sie nicht.
|
||||||
|
schritte: Array.from({ length: baustein.anzahlSchritte }, () => neuerSchritt()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Traegt Name und Fassung der verwiesenen Bausteine nach.
|
||||||
|
*
|
||||||
|
* Die gespeicherte Struktur kennt nur die Kennung; die Seite holt die Liste der
|
||||||
|
* einsetzbaren Bausteine getrennt und reicht sie hier ein. Ein Verweis, dessen
|
||||||
|
* Baustein nicht mehr einsetzbar ist (archiviert, oder eine aeltere Fassung),
|
||||||
|
* behaelt seine Kennung und bekommt einen erkennbaren Namen — verschwinden darf
|
||||||
|
* er nicht, sonst waere er beim naechsten Speichern still weg.
|
||||||
|
*/
|
||||||
|
export function bausteineAnreichern(
|
||||||
|
aufbau: EditorAufbau,
|
||||||
|
bekannte: readonly {
|
||||||
|
readonly bausteinVersionId: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly version: number;
|
||||||
|
}[],
|
||||||
|
): EditorAufbau {
|
||||||
|
const nachId = new Map(bekannte.map((eintrag) => [eintrag.bausteinVersionId, eintrag]));
|
||||||
|
return {
|
||||||
|
...aufbau,
|
||||||
|
abschnitte: aufbau.abschnitte.map((abschnitt) => {
|
||||||
|
if (abschnitt.baustein === undefined) return abschnitt;
|
||||||
|
const treffer = nachId.get(abschnitt.baustein.versionId);
|
||||||
|
return {
|
||||||
|
...abschnitt,
|
||||||
|
baustein: {
|
||||||
|
versionId: abschnitt.baustein.versionId,
|
||||||
|
name: treffer?.name ?? "(nicht mehr verfügbar)",
|
||||||
|
version: treffer?.version ?? 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function neueVariable(): EditorVariable {
|
export function neueVariable(): EditorVariable {
|
||||||
return {
|
return {
|
||||||
kennung: neueKennung("variable"),
|
kennung: neueKennung("variable"),
|
||||||
@@ -233,6 +302,11 @@ export function alsEditorAufbau(struktur: Struktur): EditorAufbau {
|
|||||||
kennung: neueKennung("abschnitt"),
|
kennung: neueKennung("abschnitt"),
|
||||||
// Die Nummer gehoert zur Position, nicht in den Titel — siehe titelOhneNummer.
|
// Die Nummer gehoert zur Position, nicht in den Titel — siehe titelOhneNummer.
|
||||||
titel: titelOhneNummer(abschnitt.titel),
|
titel: titelOhneNummer(abschnitt.titel),
|
||||||
|
// Den Namen und die Fassung kennt die Struktur nicht; die Seite reicht sie
|
||||||
|
// getrennt nach und setzt sie ueber `bausteineAnreichern` ein (#030).
|
||||||
|
...(abschnitt.bausteinVersionId === undefined
|
||||||
|
? {}
|
||||||
|
: { baustein: { versionId: abschnitt.bausteinVersionId, name: "", version: 0 } }),
|
||||||
schritte: abschnitt.schritte.map((schritt) => ({
|
schritte: abschnitt.schritte.map((schritt) => ({
|
||||||
kennung: neueKennung("schritt"),
|
kennung: neueKennung("schritt"),
|
||||||
titel: schritt.titel,
|
titel: schritt.titel,
|
||||||
@@ -266,7 +340,16 @@ export function alsStruktur(aufbau: EditorAufbau): Struktur {
|
|||||||
})),
|
})),
|
||||||
abschnitte: aufbau.abschnitte.map((abschnitt) => ({
|
abschnitte: aufbau.abschnitte.map((abschnitt) => ({
|
||||||
titel: abschnitt.titel,
|
titel: abschnitt.titel,
|
||||||
schritte: abschnitt.schritte.map((schritt) => ({
|
...(abschnitt.baustein === undefined ? {} : { bausteinVersionId: abschnitt.baustein.versionId }),
|
||||||
|
/*
|
||||||
|
* Ein Baustein-Abschnitt gibt **keine** Schritte mit: sie kommen beim
|
||||||
|
* Veroeffentlichen aus dem Baustein. Die Schritte im Zustand sind nur
|
||||||
|
* Platzhalter, damit die Anzahl in der Karte stimmt — und sie haben keinen
|
||||||
|
* Titel. Wuerden sie mitgeschickt, wiese `SchrittSchema` den ganzen
|
||||||
|
* Entwurf ab, und das Speichern scheiterte (#030, gefunden beim
|
||||||
|
* Durchklicken des gebauten Stands).
|
||||||
|
*/
|
||||||
|
schritte: (abschnitt.baustein === undefined ? abschnitt.schritte : []).map((schritt) => ({
|
||||||
titel: schritt.titel,
|
titel: schritt.titel,
|
||||||
hilfetext: schritt.hilfetext.trim() === "" ? undefined : schritt.hilfetext,
|
hilfetext: schritt.hilfetext.trim() === "" ? undefined : schritt.hilfetext,
|
||||||
typ: schritt.typ,
|
typ: schritt.typ,
|
||||||
|
|||||||
@@ -96,6 +96,17 @@ export type SchrittEingabe = z.input<typeof SchrittSchema>;
|
|||||||
|
|
||||||
export const AbschnittSchema = z.object({
|
export const AbschnittSchema = z.object({
|
||||||
titel: z.string().trim().min(1, "Ein Abschnitt braucht einen Titel.").max(GRENZEN.text),
|
titel: z.string().trim().min(1, "Ein Abschnitt braucht einen Titel.").max(GRENZEN.text),
|
||||||
|
/**
|
||||||
|
* Verweis auf eine veroeffentlichte Baustein-Version (#030).
|
||||||
|
*
|
||||||
|
* Nur im **Editor** von Belang: beim Veroeffentlichen setzt der Dienst hier die
|
||||||
|
* Schritte des Bausteins ein. Eine **Austauschdatei** trägt das Feld nicht —
|
||||||
|
* `vorlageExportieren` schreibt es nicht, und `vorlageImportieren` liest es
|
||||||
|
* nicht. Eine Kennung aus einer anderen Installation bedeutet hier nichts, und
|
||||||
|
* ein Verweis auf einen Baustein, den es dort nicht gibt, wäre eine Vorlage,
|
||||||
|
* die sich nicht veroeffentlichen laesst.
|
||||||
|
*/
|
||||||
|
bausteinVersionId: leerAlsUndefiniert(z.string().trim().max(60)),
|
||||||
schritte: z
|
schritte: z
|
||||||
.array(SchrittSchema)
|
.array(SchrittSchema)
|
||||||
.max(
|
.max(
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { z } from "@/lib/validierung";
|
||||||
|
import { AbschnittSchema } from "@/lib/vorlagen-format";
|
||||||
|
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import {
|
||||||
|
BausteinFehler,
|
||||||
|
bausteinAnlegen,
|
||||||
|
bausteinArchivieren,
|
||||||
|
bausteinBearbeiten,
|
||||||
|
bausteinEntwurfHolenOderErzeugen,
|
||||||
|
bausteinEntwurfSpeichern,
|
||||||
|
bausteinEntwurfVerwerfen,
|
||||||
|
bausteinVersionVeroeffentlichen,
|
||||||
|
} from "@/server/services/bausteine";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server Actions der Bausteine (#030).
|
||||||
|
*
|
||||||
|
* Recht ist `vorlagenBearbeiten`: wer Vorlagen pflegt, pflegt Bausteine. Ein
|
||||||
|
* Baustein ist nichts anderes als ein Abschnitt, der an mehreren Stellen steht.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type AktionsErgebnis = { readonly fehler?: string; readonly erfolg?: string };
|
||||||
|
|
||||||
|
const RECHTE_MELDUNG = "Dafür fehlt dir die Berechtigung.";
|
||||||
|
|
||||||
|
function alsErgebnis(fehler: unknown, neutral: string): AktionsErgebnis {
|
||||||
|
if (fehler instanceof BausteinFehler) return { fehler: fehler.message };
|
||||||
|
if (fehler instanceof RechteFehler) return { fehler: RECHTE_MELDUNG };
|
||||||
|
console.error(
|
||||||
|
`${neutral}:`,
|
||||||
|
fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : "unbekannter Fehler",
|
||||||
|
);
|
||||||
|
return { fehler: neutral };
|
||||||
|
}
|
||||||
|
|
||||||
|
function alles(): void {
|
||||||
|
revalidatePath("/vorlagen/bausteine");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bausteinAnlegenAktion(
|
||||||
|
_vorher: AktionsErgebnis,
|
||||||
|
formular: FormData,
|
||||||
|
): Promise<AktionsErgebnis> {
|
||||||
|
let ziel: string | undefined;
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
|
||||||
|
const eingabe = z
|
||||||
|
.object({ name: z.string().trim().min(1, "Der Baustein braucht einen Namen.") })
|
||||||
|
.safeParse({ name: formular.get("name") });
|
||||||
|
if (!eingabe.success) {
|
||||||
|
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { bausteinId } = await bausteinAnlegen({ name: eingabe.data.name }, benutzer.id);
|
||||||
|
alles();
|
||||||
|
ziel = `/vorlagen/bausteine/${bausteinId}`;
|
||||||
|
} catch (fehler) {
|
||||||
|
return alsErgebnis(fehler, "Der Baustein konnte nicht angelegt werden.");
|
||||||
|
}
|
||||||
|
// `redirect` wirft – deshalb außerhalb des try, sonst fängt der catch es ab
|
||||||
|
// und aus einem Erfolg würde eine Fehlermeldung.
|
||||||
|
redirect(ziel);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bausteinBearbeitenAktion(
|
||||||
|
_vorher: AktionsErgebnis,
|
||||||
|
formular: FormData,
|
||||||
|
): Promise<AktionsErgebnis> {
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
|
||||||
|
const eingabe = z
|
||||||
|
.object({
|
||||||
|
bausteinId: z.string().min(1),
|
||||||
|
name: z.string().trim().min(1, "Der Baustein braucht einen Namen."),
|
||||||
|
beschreibung: z.string().optional(),
|
||||||
|
})
|
||||||
|
.safeParse({
|
||||||
|
bausteinId: formular.get("bausteinId"),
|
||||||
|
name: formular.get("name"),
|
||||||
|
beschreibung: formular.get("beschreibung") ?? undefined,
|
||||||
|
});
|
||||||
|
if (!eingabe.success) {
|
||||||
|
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
|
||||||
|
}
|
||||||
|
|
||||||
|
await bausteinBearbeiten(
|
||||||
|
eingabe.data.bausteinId,
|
||||||
|
{ name: eingabe.data.name, beschreibung: eingabe.data.beschreibung ?? null },
|
||||||
|
benutzer.id,
|
||||||
|
);
|
||||||
|
alles();
|
||||||
|
revalidatePath(`/vorlagen/bausteine/${eingabe.data.bausteinId}`);
|
||||||
|
return { erfolg: "Gespeichert." };
|
||||||
|
} catch (fehler) {
|
||||||
|
return alsErgebnis(fehler, "Die Änderung konnte nicht gespeichert werden.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Speichert Titel und Schritte eines Entwurfs. Erzeugt ihn, wenn es keinen gibt. */
|
||||||
|
export async function bausteinEntwurfSpeichernAktion(
|
||||||
|
_vorher: AktionsErgebnis,
|
||||||
|
formular: FormData,
|
||||||
|
): Promise<AktionsErgebnis> {
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
|
||||||
|
const roh = formular.get("abschnitt");
|
||||||
|
if (typeof roh !== "string") return { fehler: "Die Eingabe ist unvollständig." };
|
||||||
|
|
||||||
|
const bausteinId = formular.get("bausteinId");
|
||||||
|
if (typeof bausteinId !== "string" || bausteinId.length === 0) {
|
||||||
|
return { fehler: "Die Eingabe ist unvollständig." };
|
||||||
|
}
|
||||||
|
|
||||||
|
let gelesen: unknown;
|
||||||
|
try {
|
||||||
|
gelesen = JSON.parse(roh);
|
||||||
|
} catch {
|
||||||
|
return { fehler: "Der Aufbau ließ sich nicht lesen. Bitte neu laden." };
|
||||||
|
}
|
||||||
|
const geprueft = AbschnittSchema.safeParse(gelesen);
|
||||||
|
if (!geprueft.success) {
|
||||||
|
return { fehler: geprueft.error.issues[0]?.message ?? "Der Aufbau ist unvollständig." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const versionId = await bausteinEntwurfHolenOderErzeugen(bausteinId, benutzer.id);
|
||||||
|
await bausteinEntwurfSpeichern(versionId, geprueft.data);
|
||||||
|
alles();
|
||||||
|
revalidatePath(`/vorlagen/bausteine/${bausteinId}`);
|
||||||
|
return { erfolg: "Gespeichert." };
|
||||||
|
} catch (fehler) {
|
||||||
|
return alsErgebnis(fehler, "Der Entwurf konnte nicht gespeichert werden.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bausteinVeroeffentlichenAktion(
|
||||||
|
_vorher: AktionsErgebnis,
|
||||||
|
formular: FormData,
|
||||||
|
): Promise<AktionsErgebnis> {
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
|
||||||
|
const eingabe = z
|
||||||
|
.object({
|
||||||
|
versionId: z.string().min(1),
|
||||||
|
bausteinId: z.string().min(1),
|
||||||
|
aenderungsnotiz: z.string().optional(),
|
||||||
|
})
|
||||||
|
.safeParse({
|
||||||
|
versionId: formular.get("versionId"),
|
||||||
|
bausteinId: formular.get("bausteinId"),
|
||||||
|
aenderungsnotiz: formular.get("aenderungsnotiz") ?? undefined,
|
||||||
|
});
|
||||||
|
if (!eingabe.success) return { fehler: "Die Eingabe ist unvollständig." };
|
||||||
|
|
||||||
|
await bausteinVersionVeroeffentlichen(
|
||||||
|
eingabe.data.versionId,
|
||||||
|
eingabe.data.aenderungsnotiz ?? null,
|
||||||
|
benutzer.id,
|
||||||
|
);
|
||||||
|
alles();
|
||||||
|
revalidatePath(`/vorlagen/bausteine/${eingabe.data.bausteinId}`);
|
||||||
|
// Vorlagen-Entwürfe bieten ab jetzt die neue Fassung an.
|
||||||
|
revalidatePath("/vorlagen", "layout");
|
||||||
|
return { erfolg: "Veröffentlicht. Die neue Fassung wirkt in der nächsten Vorlagenversion." };
|
||||||
|
} catch (fehler) {
|
||||||
|
return alsErgebnis(fehler, "Der Baustein konnte nicht veröffentlicht werden.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bausteinEntwurfVerwerfenAktion(
|
||||||
|
_vorher: AktionsErgebnis,
|
||||||
|
formular: FormData,
|
||||||
|
): Promise<AktionsErgebnis> {
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
|
||||||
|
const eingabe = z.object({ versionId: z.string().min(1), bausteinId: z.string().min(1) }).safeParse({
|
||||||
|
versionId: formular.get("versionId"),
|
||||||
|
bausteinId: formular.get("bausteinId"),
|
||||||
|
});
|
||||||
|
if (!eingabe.success) return { fehler: "Die Eingabe ist unvollständig." };
|
||||||
|
|
||||||
|
await bausteinEntwurfVerwerfen(eingabe.data.versionId, benutzer.id);
|
||||||
|
alles();
|
||||||
|
revalidatePath(`/vorlagen/bausteine/${eingabe.data.bausteinId}`);
|
||||||
|
return { erfolg: "Entwurf verworfen." };
|
||||||
|
} catch (fehler) {
|
||||||
|
return alsErgebnis(fehler, "Der Entwurf konnte nicht verworfen werden.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bausteinArchivierenAktion(
|
||||||
|
_vorher: AktionsErgebnis,
|
||||||
|
formular: FormData,
|
||||||
|
): Promise<AktionsErgebnis> {
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
|
||||||
|
const eingabe = z
|
||||||
|
.object({ bausteinId: z.string().min(1), archiviert: z.enum(["true", "false"]) })
|
||||||
|
.safeParse({
|
||||||
|
bausteinId: formular.get("bausteinId"),
|
||||||
|
archiviert: formular.get("archiviert"),
|
||||||
|
});
|
||||||
|
if (!eingabe.success) return { fehler: "Die Eingabe ist unvollständig." };
|
||||||
|
|
||||||
|
const archiviert = eingabe.data.archiviert === "true";
|
||||||
|
await bausteinArchivieren(eingabe.data.bausteinId, archiviert, benutzer.id);
|
||||||
|
alles();
|
||||||
|
revalidatePath(`/vorlagen/bausteine/${eingabe.data.bausteinId}`);
|
||||||
|
return { erfolg: archiviert ? "Archiviert." : "Wieder freigegeben." };
|
||||||
|
} catch (fehler) {
|
||||||
|
return alsErgebnis(fehler, "Die Änderung konnte nicht gespeichert werden.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import { VersionStatus } from "@/generated/prisma/enums";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import type { SchrittTyp } from "@/generated/prisma/enums";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lesemodelle der Bausteine (#030).
|
||||||
|
*
|
||||||
|
* Ohne Mandantensicht, und das mit Absicht: ein Baustein gehoert keinem Kunden.
|
||||||
|
* Er ist eine Quelle fuer Abschnitte, die in Vorlagen wandern — und Vorlagen
|
||||||
|
* ohne `kundeId` gelten fuer alle. Das Recht dafuer ist `vorlagenBearbeiten`:
|
||||||
|
* wer Vorlagen pflegt, pflegt Bausteine.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type BausteinSchrittZeile = {
|
||||||
|
readonly titel: string;
|
||||||
|
readonly hilfetext: string | null;
|
||||||
|
readonly typ: SchrittTyp;
|
||||||
|
readonly pflicht: boolean;
|
||||||
|
readonly konfig: unknown;
|
||||||
|
readonly dokuUrl: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BausteinZeile = {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly beschreibung: string | null;
|
||||||
|
readonly archiviert: boolean;
|
||||||
|
/** Die hoechste veroeffentlichte Version, oder `null`, wenn es noch keine gibt. */
|
||||||
|
readonly veroeffentlichteVersion: number | null;
|
||||||
|
readonly hatEntwurf: boolean;
|
||||||
|
readonly anzahlSchritte: number;
|
||||||
|
/** In wie vielen **veroeffentlichten** Vorlagenversionen er steckt. */
|
||||||
|
readonly verwendungen: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function bausteinListe(
|
||||||
|
optionen: { readonly mitArchivierten?: boolean } = {},
|
||||||
|
): Promise<readonly BausteinZeile[]> {
|
||||||
|
const zeilen = await prisma.baustein.findMany({
|
||||||
|
where: optionen.mitArchivierten ? {} : { archiviert: false },
|
||||||
|
orderBy: [{ archiviert: "asc" }, { name: "asc" }],
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
beschreibung: true,
|
||||||
|
archiviert: true,
|
||||||
|
versionen: {
|
||||||
|
orderBy: { version: "desc" },
|
||||||
|
select: {
|
||||||
|
version: true,
|
||||||
|
status: true,
|
||||||
|
_count: { select: { schritte: true, verwendungen: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return zeilen.map((zeile) => {
|
||||||
|
const veroeffentlicht = zeile.versionen.find(
|
||||||
|
(version) => version.status === VersionStatus.VEROEFFENTLICHT,
|
||||||
|
);
|
||||||
|
const entwurf = zeile.versionen.find((version) => version.status === VersionStatus.ENTWURF);
|
||||||
|
return {
|
||||||
|
id: zeile.id,
|
||||||
|
name: zeile.name,
|
||||||
|
beschreibung: zeile.beschreibung,
|
||||||
|
archiviert: zeile.archiviert,
|
||||||
|
veroeffentlichteVersion: veroeffentlicht?.version ?? null,
|
||||||
|
hatEntwurf: entwurf !== undefined,
|
||||||
|
// Die Schritte der Fassung, die zählt: der Entwurf, wenn es einen gibt,
|
||||||
|
// sonst die veröffentlichte.
|
||||||
|
anzahlSchritte: (entwurf ?? veroeffentlicht)?._count.schritte ?? 0,
|
||||||
|
verwendungen: zeile.versionen.reduce((summe, version) => summe + version._count.verwendungen, 0),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BausteinAnsicht = {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly beschreibung: string | null;
|
||||||
|
readonly archiviert: boolean;
|
||||||
|
readonly versionen: readonly {
|
||||||
|
readonly id: string;
|
||||||
|
readonly version: number;
|
||||||
|
readonly status: VersionStatus;
|
||||||
|
readonly titel: string;
|
||||||
|
readonly aenderungsnotiz: string | null;
|
||||||
|
readonly veroeffentlichtAm: Date | null;
|
||||||
|
readonly schritte: readonly BausteinSchrittZeile[];
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function bausteinHolen(bausteinId: string): Promise<BausteinAnsicht | undefined> {
|
||||||
|
const zeile = await prisma.baustein.findUnique({
|
||||||
|
where: { id: bausteinId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
beschreibung: true,
|
||||||
|
archiviert: true,
|
||||||
|
versionen: {
|
||||||
|
orderBy: { version: "desc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
version: true,
|
||||||
|
status: true,
|
||||||
|
titel: true,
|
||||||
|
aenderungsnotiz: true,
|
||||||
|
veroeffentlichtAm: true,
|
||||||
|
schritte: {
|
||||||
|
orderBy: { position: "asc" },
|
||||||
|
select: {
|
||||||
|
titel: true,
|
||||||
|
hilfetext: true,
|
||||||
|
typ: true,
|
||||||
|
pflicht: true,
|
||||||
|
konfig: true,
|
||||||
|
dokuUrl: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return zeile ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wo steckt dieser Baustein (#030)?
|
||||||
|
*
|
||||||
|
* Vor dem Aendern will man das wissen. Genannt wird die Vorlage, die Version und
|
||||||
|
* **welche Fassung** des Bausteins dort eingesetzt ist — eine Vorlage von vor
|
||||||
|
* zwei Jahren traegt eine andere als die von letzter Woche.
|
||||||
|
*/
|
||||||
|
export async function bausteinVerwendungen(bausteinId: string): Promise<
|
||||||
|
readonly {
|
||||||
|
readonly vorlageId: string;
|
||||||
|
readonly vorlageName: string;
|
||||||
|
readonly vorlagenVersion: number;
|
||||||
|
readonly status: VersionStatus;
|
||||||
|
readonly bausteinVersion: number;
|
||||||
|
readonly archiviert: boolean;
|
||||||
|
}[]
|
||||||
|
> {
|
||||||
|
const abschnitte = await prisma.vorlagenAbschnitt.findMany({
|
||||||
|
where: { bausteinVersion: { bausteinId } },
|
||||||
|
select: {
|
||||||
|
bausteinVersion: { select: { version: true } },
|
||||||
|
version: {
|
||||||
|
select: {
|
||||||
|
version: true,
|
||||||
|
status: true,
|
||||||
|
vorlage: { select: { id: true, name: true, archiviert: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return abschnitte
|
||||||
|
.flatMap((abschnitt) =>
|
||||||
|
abschnitt.bausteinVersion === null
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
vorlageId: abschnitt.version.vorlage.id,
|
||||||
|
vorlageName: abschnitt.version.vorlage.name,
|
||||||
|
vorlagenVersion: abschnitt.version.version,
|
||||||
|
status: abschnitt.version.status,
|
||||||
|
bausteinVersion: abschnitt.bausteinVersion.version,
|
||||||
|
archiviert: abschnitt.version.vorlage.archiviert,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.sort(
|
||||||
|
(links, rechts) =>
|
||||||
|
links.vorlageName.localeCompare(rechts.vorlageName, "de") ||
|
||||||
|
rechts.vorlagenVersion - links.vorlagenVersion,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Bausteine, die sich in einen Entwurf einsetzen lassen.
|
||||||
|
*
|
||||||
|
* Nur **veroeffentlichte** Fassungen nicht archivierter Bausteine, je Baustein
|
||||||
|
* die hoechste. Einen Entwurf anzubieten hiesse, beim Veroeffentlichen etwas zu
|
||||||
|
* kopieren, das sich danach noch aendert — ein Trigger weist das ohnehin ab.
|
||||||
|
*/
|
||||||
|
export async function einsetzbareBausteine(): Promise<
|
||||||
|
readonly {
|
||||||
|
readonly bausteinVersionId: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly titel: string;
|
||||||
|
readonly version: number;
|
||||||
|
readonly anzahlSchritte: number;
|
||||||
|
}[]
|
||||||
|
> {
|
||||||
|
const zeilen = await prisma.baustein.findMany({
|
||||||
|
where: { archiviert: false, versionen: { some: { status: VersionStatus.VEROEFFENTLICHT } } },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
versionen: {
|
||||||
|
where: { status: VersionStatus.VEROEFFENTLICHT },
|
||||||
|
orderBy: { version: "desc" },
|
||||||
|
take: 1,
|
||||||
|
select: { id: true, version: true, titel: true, _count: { select: { schritte: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return zeilen.flatMap((zeile) => {
|
||||||
|
const version = zeile.versionen[0];
|
||||||
|
return version === undefined
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
bausteinVersionId: version.id,
|
||||||
|
name: zeile.name,
|
||||||
|
titel: version.titel,
|
||||||
|
version: version.version,
|
||||||
|
anzahlSchritte: version._count.schritte,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
import { Prisma } from "@/generated/prisma/client";
|
||||||
|
import { VersionStatus } from "@/generated/prisma/enums";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { AbschnittSchema, type AbschnittEingabe } from "@/lib/vorlagen-format";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wiederverwendbare Bausteine (#030).
|
||||||
|
*
|
||||||
|
* Ein Baustein ist **ein Abschnitt**, den mehrere Vorlagen teilen — Titel und
|
||||||
|
* Schritte. Versioniert nach denselben Regeln wie eine Vorlage: hoechstens ein
|
||||||
|
* Entwurf, eine veroeffentlichte Version wird nie veraendert, und beides haelt
|
||||||
|
* die Datenbank fest (partieller Index und Trigger), nicht nur dieser Dienst.
|
||||||
|
*
|
||||||
|
* Eingesetzt wird er beim **Veroeffentlichen einer Vorlage**, als tiefe Kopie —
|
||||||
|
* siehe `bausteineAufloesen` in `vorlagen.ts`. Dieser Dienst weiss davon nichts;
|
||||||
|
* er pflegt nur die Quelle.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class BausteinFehler extends Error {
|
||||||
|
constructor(meldung: string) {
|
||||||
|
super(meldung);
|
||||||
|
this.name = "BausteinFehler";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function namePruefen(roh: string): string {
|
||||||
|
const name = roh.trim();
|
||||||
|
if (!name) throw new BausteinFehler("Der Baustein braucht einen Namen.");
|
||||||
|
if (name.length > 200) throw new BausteinFehler("Der Name ist zu lang (höchstens 200 Zeichen).");
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Uebersetzt den Eindeutigkeitsfehler des Namens in einen Satz. */
|
||||||
|
function alsNamensfehler(fehler: unknown, name: string): never {
|
||||||
|
if (fehler instanceof Prisma.PrismaClientKnownRequestError && fehler.code === "P2002") {
|
||||||
|
throw new BausteinFehler(`Es gibt schon einen Baustein „${name}“. Namen sind eindeutig.`);
|
||||||
|
}
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt einen Baustein mit Entwurf in Version 1 an.
|
||||||
|
*
|
||||||
|
* Der Titel des Abschnitts ist zu Beginn der Name — in den meisten Faellen
|
||||||
|
* stimmt das, und wer es anders will, aendert ihn im Entwurf.
|
||||||
|
*/
|
||||||
|
export async function bausteinAnlegen(
|
||||||
|
eingabe: { readonly name: string; readonly beschreibung?: string | null },
|
||||||
|
durchBenutzerId: string,
|
||||||
|
): Promise<{ readonly bausteinId: string; readonly versionId: string }> {
|
||||||
|
const name = namePruefen(eingabe.name);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await prisma.$transaction(async (tx) => {
|
||||||
|
const baustein = await tx.baustein.create({
|
||||||
|
data: { name, beschreibung: eingabe.beschreibung?.trim() || null },
|
||||||
|
});
|
||||||
|
const version = await tx.bausteinVersion.create({
|
||||||
|
data: { bausteinId: baustein.id, version: 1, status: VersionStatus.ENTWURF, titel: name },
|
||||||
|
});
|
||||||
|
await tx.protokoll.create({
|
||||||
|
data: {
|
||||||
|
entitaet: "Baustein",
|
||||||
|
entitaetId: baustein.id,
|
||||||
|
aktion: "Baustein angelegt",
|
||||||
|
benutzerId: durchBenutzerId,
|
||||||
|
nachher: { name },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { bausteinId: baustein.id, versionId: version.id };
|
||||||
|
});
|
||||||
|
} catch (fehler) {
|
||||||
|
alsNamensfehler(fehler, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bausteinBearbeiten(
|
||||||
|
bausteinId: string,
|
||||||
|
aenderung: { readonly name?: string; readonly beschreibung?: string | null },
|
||||||
|
durchBenutzerId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const vorher = await prisma.baustein.findUnique({ where: { id: bausteinId } });
|
||||||
|
if (!vorher) throw new BausteinFehler("Diesen Baustein gibt es nicht (mehr).");
|
||||||
|
|
||||||
|
const daten: { name?: string; beschreibung?: string | null } = {};
|
||||||
|
const geaendert: string[] = [];
|
||||||
|
if (aenderung.name !== undefined) {
|
||||||
|
const name = namePruefen(aenderung.name);
|
||||||
|
if (name !== vorher.name) {
|
||||||
|
daten.name = name;
|
||||||
|
geaendert.push("Name");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (aenderung.beschreibung !== undefined) {
|
||||||
|
const text = aenderung.beschreibung?.trim() || null;
|
||||||
|
if (text !== vorher.beschreibung) {
|
||||||
|
daten.beschreibung = text;
|
||||||
|
geaendert.push("Beschreibung");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (geaendert.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.baustein.update({ where: { id: bausteinId }, data: daten });
|
||||||
|
await tx.protokoll.create({
|
||||||
|
data: {
|
||||||
|
entitaet: "Baustein",
|
||||||
|
entitaetId: bausteinId,
|
||||||
|
aktion: `${geaendert.join(" und ")} geändert`,
|
||||||
|
benutzerId: durchBenutzerId,
|
||||||
|
vorher: { name: vorher.name, beschreibung: vorher.beschreibung },
|
||||||
|
nachher: {
|
||||||
|
name: daten.name ?? vorher.name,
|
||||||
|
beschreibung: daten.beschreibung ?? vorher.beschreibung,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (fehler) {
|
||||||
|
alsNamensfehler(fehler, daten.name ?? vorher.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holt den Entwurf oder erzeugt ihn als tiefe Kopie der letzten
|
||||||
|
* veroeffentlichten Version.
|
||||||
|
*
|
||||||
|
* Dieselbe Regel wie bei Vorlagen: „Bearbeiten“ an etwas Veroeffentlichtem
|
||||||
|
* bedeutet immer eine Kopie, nie eine Aenderung.
|
||||||
|
*/
|
||||||
|
export async function bausteinEntwurfHolenOderErzeugen(
|
||||||
|
bausteinId: string,
|
||||||
|
durchBenutzerId: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const vorhanden = await prisma.bausteinVersion.findFirst({
|
||||||
|
where: { bausteinId, status: VersionStatus.ENTWURF },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (vorhanden) return vorhanden.id;
|
||||||
|
|
||||||
|
const letzte = await prisma.bausteinVersion.findFirst({
|
||||||
|
where: { bausteinId, status: VersionStatus.VEROEFFENTLICHT },
|
||||||
|
orderBy: { version: "desc" },
|
||||||
|
include: { schritte: { orderBy: { position: "asc" } } },
|
||||||
|
});
|
||||||
|
if (!letzte) {
|
||||||
|
throw new BausteinFehler("Dieser Baustein hat keine Version, aus der sich ein Entwurf machen ließe.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
const entwurf = await tx.bausteinVersion.create({
|
||||||
|
data: {
|
||||||
|
bausteinId,
|
||||||
|
version: letzte.version + 1,
|
||||||
|
status: VersionStatus.ENTWURF,
|
||||||
|
titel: letzte.titel,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (letzte.schritte.length > 0) {
|
||||||
|
await tx.bausteinSchritt.createMany({
|
||||||
|
data: letzte.schritte.map((schritt, index) => ({
|
||||||
|
versionId: entwurf.id,
|
||||||
|
titel: schritt.titel,
|
||||||
|
hilfetext: schritt.hilfetext,
|
||||||
|
typ: schritt.typ,
|
||||||
|
pflicht: schritt.pflicht,
|
||||||
|
konfig: schritt.konfig as Prisma.InputJsonValue,
|
||||||
|
dokuUrl: schritt.dokuUrl,
|
||||||
|
position: index,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await tx.protokoll.create({
|
||||||
|
data: {
|
||||||
|
entitaet: "BausteinVersion",
|
||||||
|
entitaetId: entwurf.id,
|
||||||
|
aktion: "Entwurf aus veröffentlichter Version erstellt",
|
||||||
|
benutzerId: durchBenutzerId,
|
||||||
|
nachher: { version: entwurf.version, ausVersion: letzte.version },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return entwurf.id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ersetzt Titel und Schritte eines Entwurfs vollstaendig.
|
||||||
|
*
|
||||||
|
* Als Ganzes und nicht Schritt fuer Schritt — dieselbe Entscheidung wie bei
|
||||||
|
* `entwurfSpeichern` der Vorlagen: der Editor haelt die Liste im Speicher, und
|
||||||
|
* ein Teil-Update waere mit dem Sortieren im Streit.
|
||||||
|
*
|
||||||
|
* Kein Protokolleintrag: ein Entwurf wird beim Bauen dutzendfach gespeichert.
|
||||||
|
* Festgehalten wird das Veroeffentlichen.
|
||||||
|
*/
|
||||||
|
export async function bausteinEntwurfSpeichern(versionId: string, eingabe: AbschnittEingabe): Promise<void> {
|
||||||
|
const gelesen = AbschnittSchema.safeParse(eingabe);
|
||||||
|
if (!gelesen.success) {
|
||||||
|
throw new BausteinFehler(gelesen.error.issues[0]?.message ?? "Der Aufbau ist unvollständig.");
|
||||||
|
}
|
||||||
|
const abschnitt = gelesen.data;
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
const zeile = await tx.bausteinVersion.findUnique({
|
||||||
|
where: { id: versionId },
|
||||||
|
select: { status: true, version: true },
|
||||||
|
});
|
||||||
|
if (!zeile) throw new BausteinFehler("Diese Version gibt es nicht.");
|
||||||
|
if (zeile.status !== VersionStatus.ENTWURF) {
|
||||||
|
throw new BausteinFehler(
|
||||||
|
`Version ${zeile.version} ist veröffentlicht und damit unveränderlich. ` +
|
||||||
|
"„Bearbeiten“ erzeugt daraus einen neuen Entwurf.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.bausteinSchritt.deleteMany({ where: { versionId } });
|
||||||
|
if (abschnitt.schritte.length > 0) {
|
||||||
|
await tx.bausteinSchritt.createMany({
|
||||||
|
data: abschnitt.schritte.map((schritt, index) => ({
|
||||||
|
versionId,
|
||||||
|
titel: schritt.titel,
|
||||||
|
hilfetext: schritt.hilfetext ?? null,
|
||||||
|
typ: schritt.typ,
|
||||||
|
pflicht: schritt.pflicht,
|
||||||
|
konfig: schritt.konfig as Prisma.InputJsonValue,
|
||||||
|
dokuUrl: schritt.dokuUrl ?? null,
|
||||||
|
position: index,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await tx.bausteinVersion.update({
|
||||||
|
where: { id: versionId },
|
||||||
|
data: { titel: abschnitt.titel, geaendertAm: new Date() },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Veroeffentlicht einen Entwurf.
|
||||||
|
*
|
||||||
|
* **Wirkt erst in der naechsten veroeffentlichten Vorlagenversion.** Weder eine
|
||||||
|
* bestehende Vorlagenversion noch ein laufender Lauf aendert sich dadurch — die
|
||||||
|
* tragen Kopien.
|
||||||
|
*/
|
||||||
|
export async function bausteinVersionVeroeffentlichen(
|
||||||
|
versionId: string,
|
||||||
|
aenderungsnotiz: string | null,
|
||||||
|
durchBenutzerId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const notiz = aenderungsnotiz?.trim() || null;
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
const zeile = await tx.bausteinVersion.findUnique({
|
||||||
|
where: { id: versionId },
|
||||||
|
select: {
|
||||||
|
status: true,
|
||||||
|
version: true,
|
||||||
|
baustein: { select: { name: true, archiviert: true } },
|
||||||
|
_count: { select: { schritte: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!zeile) throw new BausteinFehler("Diese Version gibt es nicht.");
|
||||||
|
if (zeile.status !== VersionStatus.ENTWURF) {
|
||||||
|
throw new BausteinFehler("Diese Version wurde bereits veröffentlicht.");
|
||||||
|
}
|
||||||
|
if (zeile.baustein.archiviert) {
|
||||||
|
throw new BausteinFehler(
|
||||||
|
`„${zeile.baustein.name}“ ist archiviert. Erst wieder freigeben, dann veröffentlichen.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (zeile._count.schritte === 0) {
|
||||||
|
throw new BausteinFehler(
|
||||||
|
"Ein Baustein ohne Schritte lässt sich nicht veröffentlichen — er würde in jeder Vorlage " +
|
||||||
|
"einen leeren Abschnitt erzeugen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getroffen = await tx.bausteinVersion.updateMany({
|
||||||
|
where: { id: versionId, status: VersionStatus.ENTWURF },
|
||||||
|
data: {
|
||||||
|
status: VersionStatus.VEROEFFENTLICHT,
|
||||||
|
aenderungsnotiz: notiz,
|
||||||
|
veroeffentlichtAm: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (getroffen.count === 0) throw new BausteinFehler("Diese Version wurde bereits veröffentlicht.");
|
||||||
|
|
||||||
|
await tx.protokoll.create({
|
||||||
|
data: {
|
||||||
|
entitaet: "BausteinVersion",
|
||||||
|
entitaetId: versionId,
|
||||||
|
aktion: "Baustein-Version veröffentlicht",
|
||||||
|
benutzerId: durchBenutzerId,
|
||||||
|
vorher: { status: VersionStatus.ENTWURF },
|
||||||
|
nachher: {
|
||||||
|
status: VersionStatus.VEROEFFENTLICHT,
|
||||||
|
baustein: zeile.baustein.name,
|
||||||
|
version: zeile.version,
|
||||||
|
aenderungsnotiz: notiz,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bausteinEntwurfVerwerfen(versionId: string, durchBenutzerId: string): Promise<void> {
|
||||||
|
const version = await prisma.bausteinVersion.findUnique({
|
||||||
|
where: { id: versionId },
|
||||||
|
select: { status: true, version: true, bausteinId: true },
|
||||||
|
});
|
||||||
|
if (!version) throw new BausteinFehler("Diese Version gibt es nicht.");
|
||||||
|
if (version.status !== VersionStatus.ENTWURF) {
|
||||||
|
throw new BausteinFehler("Eine veröffentlichte Version lässt sich nicht verwerfen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const veroeffentlichte = await prisma.bausteinVersion.count({
|
||||||
|
where: { bausteinId: version.bausteinId, status: VersionStatus.VEROEFFENTLICHT },
|
||||||
|
});
|
||||||
|
if (veroeffentlichte === 0) {
|
||||||
|
throw new BausteinFehler(
|
||||||
|
"Das ist die einzige Version dieses Bausteins. Statt sie zu verwerfen, archiviere den Baustein.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.bausteinVersion.delete({ where: { id: versionId } });
|
||||||
|
await tx.protokoll.create({
|
||||||
|
data: {
|
||||||
|
entitaet: "BausteinVersion",
|
||||||
|
entitaetId: versionId,
|
||||||
|
aktion: "Entwurf verworfen",
|
||||||
|
benutzerId: durchBenutzerId,
|
||||||
|
vorher: { version: version.version },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Archivieren statt loeschen.
|
||||||
|
*
|
||||||
|
* Geloescht wird ein Baustein nie: veroeffentlichte Vorlagenversionen tragen
|
||||||
|
* seinen Verweis als Herkunft, und der Fremdschluessel ist `RESTRICT`. Ein
|
||||||
|
* archivierter Baustein laesst sich nicht mehr neu einsetzen, bleibt aber in
|
||||||
|
* jeder Vorlage lesbar, die ihn schon benutzt.
|
||||||
|
*/
|
||||||
|
export async function bausteinArchivieren(
|
||||||
|
bausteinId: string,
|
||||||
|
archiviert: boolean,
|
||||||
|
durchBenutzerId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const vorher = await prisma.baustein.findUnique({ where: { id: bausteinId } });
|
||||||
|
if (!vorher) throw new BausteinFehler("Diesen Baustein gibt es nicht (mehr).");
|
||||||
|
if (vorher.archiviert === archiviert) return;
|
||||||
|
|
||||||
|
if (archiviert) {
|
||||||
|
const inEntwuerfen = await prisma.vorlagenAbschnitt.count({
|
||||||
|
where: {
|
||||||
|
bausteinVersion: { bausteinId },
|
||||||
|
version: { status: VersionStatus.ENTWURF },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (inEntwuerfen > 0) {
|
||||||
|
throw new BausteinFehler(
|
||||||
|
`„${vorher.name}“ steht in ${inEntwuerfen === 1 ? "einem Entwurf" : `${inEntwuerfen} Entwürfen`}. ` +
|
||||||
|
"Diese Vorlagen ließen sich danach nicht veröffentlichen — erst dort herausnehmen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.baustein.update({ where: { id: bausteinId }, data: { archiviert } });
|
||||||
|
await tx.protokoll.create({
|
||||||
|
data: {
|
||||||
|
entitaet: "Baustein",
|
||||||
|
entitaetId: bausteinId,
|
||||||
|
aktion: archiviert ? "Baustein archiviert" : "Baustein wieder freigegeben",
|
||||||
|
benutzerId: durchBenutzerId,
|
||||||
|
vorher: { archiviert: vorher.archiviert },
|
||||||
|
nachher: { archiviert },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -364,6 +364,89 @@ export function versionMussEntwurfSein(version: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt die Schritte verwiesener Bausteine in den Entwurf ein (#030).
|
||||||
|
*
|
||||||
|
* Der Kern der Entscheidung aus dem Issue: ein Entwurf **verweist**, eine
|
||||||
|
* veroeffentlichte Version traegt **Kopien**. Danach sieht ein Lauf weiterhin
|
||||||
|
* konkrete Schritte, und `Schrittergebnis`, Druckansicht und Archiv muessen
|
||||||
|
* nichts von Bausteinen wissen. Eine spaetere Baustein-Fassung kann diese
|
||||||
|
* Version nicht mehr anfassen — genau das soll die Unveraenderlichkeit leisten.
|
||||||
|
*
|
||||||
|
* Der Verweis bleibt stehen. In einer veroeffentlichten Version ist er nur noch
|
||||||
|
* Herkunft, und die will man beim Lesen sehen.
|
||||||
|
*
|
||||||
|
* Gibt zurueck, was eingesetzt wurde — fuer den Protokolleintrag.
|
||||||
|
*/
|
||||||
|
async function bausteineAufloesen(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
versionId: string,
|
||||||
|
): Promise<readonly { readonly name: string; readonly version: number }[]> {
|
||||||
|
const abschnitte = await tx.vorlagenAbschnitt.findMany({
|
||||||
|
where: { versionId, bausteinVersionId: { not: null } },
|
||||||
|
orderBy: { position: "asc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
titel: true,
|
||||||
|
bausteinVersion: {
|
||||||
|
select: {
|
||||||
|
version: true,
|
||||||
|
titel: true,
|
||||||
|
baustein: { select: { name: true, archiviert: true } },
|
||||||
|
schritte: { orderBy: { position: "asc" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: { select: { schritte: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const eingesetzt: { name: string; version: number }[] = [];
|
||||||
|
for (const abschnitt of abschnitte) {
|
||||||
|
const quelle = abschnitt.bausteinVersion;
|
||||||
|
if (!quelle) continue;
|
||||||
|
if (quelle.baustein.archiviert) {
|
||||||
|
throw new VorlagenFehler(
|
||||||
|
`Der Baustein „${quelle.baustein.name}“ ist archiviert. Erst wieder freigeben, ` +
|
||||||
|
"dann veröffentlichen — oder den Abschnitt aus dem Entwurf nehmen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (quelle.schritte.length === 0) {
|
||||||
|
throw new VorlagenFehler(
|
||||||
|
`Der Baustein „${quelle.baustein.name}“ hat keine Schritte. Ein Abschnitt daraus ` +
|
||||||
|
"wäre leer, und eine Vorlage mit leerem Abschnitt lässt sich nicht abarbeiten.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Eigene Schritte hat ein Baustein-Abschnitt nicht; falls doch etwas liegen
|
||||||
|
// geblieben ist, weg damit, sonst stuenden die Schritte doppelt.
|
||||||
|
if (abschnitt._count.schritte > 0) {
|
||||||
|
await tx.vorlagenSchritt.deleteMany({ where: { abschnittId: abschnitt.id } });
|
||||||
|
}
|
||||||
|
await tx.vorlagenSchritt.createMany({
|
||||||
|
data: quelle.schritte.map((schritt, index) => ({
|
||||||
|
abschnittId: abschnitt.id,
|
||||||
|
titel: schritt.titel,
|
||||||
|
hilfetext: schritt.hilfetext,
|
||||||
|
typ: schritt.typ,
|
||||||
|
pflicht: schritt.pflicht,
|
||||||
|
konfig: schritt.konfig as Prisma.InputJsonValue,
|
||||||
|
dokuUrl: schritt.dokuUrl,
|
||||||
|
position: index,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
// Der Titel kommt aus dem Baustein: er gehoert zum Abschnitt, den der
|
||||||
|
// Baustein beschreibt, und soll nicht in jeder Vorlage einzeln gepflegt
|
||||||
|
// werden.
|
||||||
|
if (abschnitt.titel !== quelle.titel) {
|
||||||
|
await tx.vorlagenAbschnitt.update({
|
||||||
|
where: { id: abschnitt.id },
|
||||||
|
data: { titel: quelle.titel },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
eingesetzt.push({ name: quelle.baustein.name, version: quelle.version });
|
||||||
|
}
|
||||||
|
return eingesetzt;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ersetzt den Aufbau eines Entwurfs vollstaendig.
|
* Ersetzt den Aufbau eines Entwurfs vollstaendig.
|
||||||
*
|
*
|
||||||
@@ -443,7 +526,14 @@ export async function entwurfSpeichern(
|
|||||||
|
|
||||||
for (const [index, abschnitt] of struktur.abschnitte.entries()) {
|
for (const [index, abschnitt] of struktur.abschnitte.entries()) {
|
||||||
const angelegt = await tx.vorlagenAbschnitt.create({
|
const angelegt = await tx.vorlagenAbschnitt.create({
|
||||||
data: { versionId, titel: abschnitt.titel, position: index },
|
data: {
|
||||||
|
versionId,
|
||||||
|
titel: abschnitt.titel,
|
||||||
|
position: index,
|
||||||
|
// Ein Trigger laesst nur veroeffentlichte Baustein-Versionen zu; der
|
||||||
|
// Dienst prueft dasselbe mit besserer Meldung (#030).
|
||||||
|
bausteinVersionId: abschnitt.bausteinVersionId ?? null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (abschnitt.schritte.length > 0) {
|
if (abschnitt.schritte.length > 0) {
|
||||||
await tx.vorlagenSchritt.createMany({
|
await tx.vorlagenSchritt.createMany({
|
||||||
@@ -509,6 +599,11 @@ export async function versionVeroeffentlichen(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// **Vor** dem Umschalten: Bausteine in echte Schritte aufloesen. Danach
|
||||||
|
// greifen die Trigger auf veroeffentlichten Versionen, und es waere zu spaet
|
||||||
|
// (#030).
|
||||||
|
const eingesetzt = await bausteineAufloesen(tx, versionId);
|
||||||
|
|
||||||
// Bedingtes Update: trifft es keine Zeile, hat jemand anderes in derselben
|
// Bedingtes Update: trifft es keine Zeile, hat jemand anderes in derselben
|
||||||
// Sekunde veroeffentlicht. Ohne diese Bedingung entstuenden zwei
|
// Sekunde veroeffentlicht. Ohne diese Bedingung entstuenden zwei
|
||||||
// Protokolleintraege fuer denselben Vorgang.
|
// Protokolleintraege fuer denselben Vorgang.
|
||||||
@@ -537,6 +632,10 @@ export async function versionVeroeffentlichen(
|
|||||||
status: VersionStatus.VEROEFFENTLICHT,
|
status: VersionStatus.VEROEFFENTLICHT,
|
||||||
version: zeile.version,
|
version: zeile.version,
|
||||||
aenderungsnotiz: notiz,
|
aenderungsnotiz: notiz,
|
||||||
|
// Welche Bausteine in dieser Version steckengeblieben sind, in welcher
|
||||||
|
// Fassung. Spaeter die einzige Stelle, an der das noch nachlesbar ist:
|
||||||
|
// der Verweis am Abschnitt nennt die Kennung, nicht den Namen.
|
||||||
|
...(eingesetzt.length > 0 ? { bausteine: eingesetzt } : {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,552 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { VersionStatus } from "@/generated/prisma/enums";
|
||||||
|
import {
|
||||||
|
BausteinFehler,
|
||||||
|
bausteinAnlegen,
|
||||||
|
bausteinArchivieren,
|
||||||
|
bausteinBearbeiten,
|
||||||
|
bausteinEntwurfHolenOderErzeugen,
|
||||||
|
bausteinEntwurfSpeichern,
|
||||||
|
bausteinEntwurfVerwerfen,
|
||||||
|
bausteinVersionVeroeffentlichen,
|
||||||
|
} from "@/server/services/bausteine";
|
||||||
|
import {
|
||||||
|
bausteinHolen,
|
||||||
|
bausteinListe,
|
||||||
|
bausteinVerwendungen,
|
||||||
|
einsetzbareBausteine,
|
||||||
|
} from "@/server/queries/bausteine";
|
||||||
|
import {
|
||||||
|
VorlagenFehler,
|
||||||
|
entwurfHolenOderErzeugen,
|
||||||
|
entwurfSpeichern,
|
||||||
|
versionVeroeffentlichen,
|
||||||
|
vorlageAnlegen,
|
||||||
|
} from "@/server/services/vorlagen";
|
||||||
|
import { vorlageExportieren, vorlageImportieren } from "@/server/services/vorlagen-austausch";
|
||||||
|
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wiederverwendbare Bausteine (#030).
|
||||||
|
*
|
||||||
|
* Die eine Zusage, die alles traegt: **eine Baustein-Aenderung fasst nichts
|
||||||
|
* Veroeffentlichtes an.** Ein Entwurf verweist, eine veroeffentlichte Version
|
||||||
|
* traegt Kopien — und ein Lauf sieht deshalb weiterhin konkrete Schritte.
|
||||||
|
*/
|
||||||
|
|
||||||
|
async function grundbestand() {
|
||||||
|
const admin = await testPrisma.benutzer.create({
|
||||||
|
data: { email: "admin@firma.example", name: "Admin", rolle: "ADMIN" },
|
||||||
|
});
|
||||||
|
return { adminId: admin.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein veroeffentlichter Baustein mit den uebergebenen Schritttiteln. */
|
||||||
|
async function bausteinMitSchritten(
|
||||||
|
name: string,
|
||||||
|
titel: string,
|
||||||
|
schritte: readonly string[],
|
||||||
|
adminId: string,
|
||||||
|
): Promise<{ bausteinId: string; versionId: string }> {
|
||||||
|
const { bausteinId, versionId } = await bausteinAnlegen({ name }, adminId);
|
||||||
|
await bausteinEntwurfSpeichern(versionId, {
|
||||||
|
titel,
|
||||||
|
schritte: schritte.map((schritt) => ({ titel: schritt })),
|
||||||
|
});
|
||||||
|
await bausteinVersionVeroeffentlichen(versionId, null, adminId);
|
||||||
|
return { bausteinId, versionId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine Vorlage mit einem Baustein-Abschnitt und einem eigenen, veroeffentlicht. */
|
||||||
|
async function vorlageMitBaustein(
|
||||||
|
name: string,
|
||||||
|
bausteinVersionId: string,
|
||||||
|
adminId: string,
|
||||||
|
): Promise<{ vorlageId: string; versionId: string }> {
|
||||||
|
const { vorlage } = await vorlageAnlegen({ name }, adminId);
|
||||||
|
const entwurf = await entwurfHolenOderErzeugen(vorlage.id, adminId);
|
||||||
|
const versionId = entwurf.id;
|
||||||
|
await entwurfSpeichern(versionId, {
|
||||||
|
variablen: [],
|
||||||
|
abschnitte: [
|
||||||
|
{ titel: "Eigener Abschnitt", schritte: [{ titel: "Eigener Schritt" }] },
|
||||||
|
{ titel: "wird ersetzt", bausteinVersionId, schritte: [] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await versionVeroeffentlichen(versionId, null, adminId);
|
||||||
|
return { vorlageId: vorlage.id, versionId };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function schritteVon(versionId: string): Promise<readonly string[]> {
|
||||||
|
const abschnitte = await testPrisma.vorlagenAbschnitt.findMany({
|
||||||
|
where: { versionId },
|
||||||
|
orderBy: { position: "asc" },
|
||||||
|
include: { schritte: { orderBy: { position: "asc" } } },
|
||||||
|
});
|
||||||
|
return abschnitte.flatMap((abschnitt) => [
|
||||||
|
`# ${abschnitt.titel}`,
|
||||||
|
...abschnitt.schritte.map((schritt) => schritt.titel),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.runIf(datenbankVerfuegbar)("Bausteine", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await datenbankLeeren();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Pflegen", () => {
|
||||||
|
it("legt einen Baustein mit Entwurf in Version 1 an", async () => {
|
||||||
|
const { adminId } = await grundbestand();
|
||||||
|
const { bausteinId, versionId } = await bausteinAnlegen({ name: "Datensicherung prüfen" }, adminId);
|
||||||
|
|
||||||
|
const ansicht = await bausteinHolen(bausteinId);
|
||||||
|
expect(ansicht?.name).toBe("Datensicherung prüfen");
|
||||||
|
expect(ansicht?.versionen).toHaveLength(1);
|
||||||
|
expect(ansicht?.versionen[0]).toMatchObject({
|
||||||
|
id: versionId,
|
||||||
|
version: 1,
|
||||||
|
status: VersionStatus.ENTWURF,
|
||||||
|
// Der Titel beginnt als Name – in den meisten Fällen stimmt das.
|
||||||
|
titel: "Datensicherung prüfen",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hält Namen eindeutig, mit einem Satz statt eines Datenbankfehlers", async () => {
|
||||||
|
const { adminId } = await grundbestand();
|
||||||
|
await bausteinAnlegen({ name: "Datensicherung prüfen" }, adminId);
|
||||||
|
await expect(bausteinAnlegen({ name: "Datensicherung prüfen" }, adminId)).rejects.toThrow(
|
||||||
|
/gibt schon einen Baustein/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lässt je Baustein höchstens einen Entwurf zu – auch an der Service-Schicht vorbei", async () => {
|
||||||
|
const { adminId } = await grundbestand();
|
||||||
|
const { bausteinId } = await bausteinAnlegen({ name: "Einmal" }, adminId);
|
||||||
|
await expect(
|
||||||
|
testPrisma.bausteinVersion.create({
|
||||||
|
data: { bausteinId, version: 2, status: VersionStatus.ENTWURF, titel: "zweiter" },
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/BausteinVersion_ein_entwurf/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weist einen Baustein ohne Schritte beim Veröffentlichen ab", async () => {
|
||||||
|
const { adminId } = await grundbestand();
|
||||||
|
const { versionId } = await bausteinAnlegen({ name: "Leer" }, adminId);
|
||||||
|
await expect(bausteinVersionVeroeffentlichen(versionId, null, adminId)).rejects.toThrow(
|
||||||
|
/ohne Schritte/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("macht aus einer veröffentlichten Version einen Entwurf als tiefe Kopie", async () => {
|
||||||
|
const { adminId } = await grundbestand();
|
||||||
|
const { bausteinId } = await bausteinMitSchritten(
|
||||||
|
"Datensicherung prüfen",
|
||||||
|
"Datensicherung",
|
||||||
|
["Sicherung läuft", "Rücksicherung getestet"],
|
||||||
|
adminId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const entwurfId = await bausteinEntwurfHolenOderErzeugen(bausteinId, adminId);
|
||||||
|
const ansicht = await bausteinHolen(bausteinId);
|
||||||
|
expect(ansicht?.versionen).toHaveLength(2);
|
||||||
|
const entwurf = ansicht?.versionen.find((version) => version.id === entwurfId);
|
||||||
|
expect(entwurf?.version).toBe(2);
|
||||||
|
expect(entwurf?.schritte.map((schritt) => schritt.titel)).toEqual([
|
||||||
|
"Sicherung läuft",
|
||||||
|
"Rücksicherung getestet",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Zweiter Aufruf gibt denselben Entwurf, nicht einen dritten.
|
||||||
|
expect(await bausteinEntwurfHolenOderErzeugen(bausteinId, adminId)).toBe(entwurfId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lässt eine veröffentlichte Version nicht ändern – der Trigger hält das fest", async () => {
|
||||||
|
const { adminId } = await grundbestand();
|
||||||
|
const { versionId } = await bausteinMitSchritten("Fest", "Fest", ["Eins"], adminId);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
bausteinEntwurfSpeichern(versionId, { titel: "anders", schritte: [{ titel: "Zwei" }] }),
|
||||||
|
).rejects.toThrow(/unveränderlich/);
|
||||||
|
// Und auch an der Service-Schicht vorbei nicht.
|
||||||
|
await expect(
|
||||||
|
testPrisma.bausteinSchritt.create({ data: { versionId, titel: "Geschmuggelt" } }),
|
||||||
|
).rejects.toThrow(/unveraenderlich/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lässt eine veröffentlichte Version nicht zum Entwurf zurückmachen", async () => {
|
||||||
|
const { adminId } = await grundbestand();
|
||||||
|
const { versionId } = await bausteinMitSchritten("Einbahn", "Einbahn", ["Eins"], adminId);
|
||||||
|
await expect(
|
||||||
|
testPrisma.bausteinVersion.update({
|
||||||
|
where: { id: versionId },
|
||||||
|
data: { status: VersionStatus.ENTWURF },
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/nicht wieder zum Entwurf/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verwirft einen Entwurf nur, wenn es noch eine veröffentlichte Version gibt", async () => {
|
||||||
|
const { adminId } = await grundbestand();
|
||||||
|
const { bausteinId, versionId } = await bausteinAnlegen({ name: "Einzig" }, adminId);
|
||||||
|
await expect(bausteinEntwurfVerwerfen(versionId, adminId)).rejects.toThrow(/einzige Version/);
|
||||||
|
|
||||||
|
await bausteinEntwurfSpeichern(versionId, { titel: "Einzig", schritte: [{ titel: "Eins" }] });
|
||||||
|
await bausteinVersionVeroeffentlichen(versionId, null, adminId);
|
||||||
|
const zweiter = await bausteinEntwurfHolenOderErzeugen(bausteinId, adminId);
|
||||||
|
await bausteinEntwurfVerwerfen(zweiter, adminId);
|
||||||
|
expect((await bausteinHolen(bausteinId))?.versionen).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("benennt um und protokolliert", async () => {
|
||||||
|
const { adminId } = await grundbestand();
|
||||||
|
const { bausteinId } = await bausteinAnlegen({ name: "Alt" }, adminId);
|
||||||
|
await bausteinBearbeiten(bausteinId, { name: "Neu", beschreibung: "Erklärung" }, adminId);
|
||||||
|
|
||||||
|
expect((await bausteinHolen(bausteinId))?.name).toBe("Neu");
|
||||||
|
const eintraege = await testPrisma.protokoll.findMany({
|
||||||
|
where: { entitaet: "Baustein", entitaetId: bausteinId },
|
||||||
|
orderBy: { erstelltAm: "asc" },
|
||||||
|
select: { aktion: true },
|
||||||
|
});
|
||||||
|
expect(eintraege.map((eintrag) => eintrag.aktion)).toEqual([
|
||||||
|
"Baustein angelegt",
|
||||||
|
"Name und Beschreibung geändert",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Einsetzen und Auflösen", () => {
|
||||||
|
it("setzt die Schritte des Bausteins beim Veröffentlichen ein", async () => {
|
||||||
|
const { adminId } = await grundbestand();
|
||||||
|
const { versionId: bausteinVersionId } = await bausteinMitSchritten(
|
||||||
|
"Datensicherung prüfen",
|
||||||
|
"Datensicherung",
|
||||||
|
["Sicherung läuft", "Rücksicherung getestet"],
|
||||||
|
adminId,
|
||||||
|
);
|
||||||
|
const { versionId } = await vorlageMitBaustein("PC Neuinstallation", bausteinVersionId, adminId);
|
||||||
|
|
||||||
|
expect(await schritteVon(versionId)).toEqual([
|
||||||
|
"# Eigener Abschnitt",
|
||||||
|
"Eigener Schritt",
|
||||||
|
// Titel und Schritte kommen aus dem Baustein, nicht aus dem Entwurf.
|
||||||
|
"# Datensicherung",
|
||||||
|
"Sicherung läuft",
|
||||||
|
"Rücksicherung getestet",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lässt den Verweis als Herkunft stehen", async () => {
|
||||||
|
const { adminId } = await grundbestand();
|
||||||
|
const { versionId: bausteinVersionId } = await bausteinMitSchritten(
|
||||||
|
"Herkunft",
|
||||||
|
"Herkunft",
|
||||||
|
["Eins"],
|
||||||
|
adminId,
|
||||||
|
);
|
||||||
|
const { versionId } = await vorlageMitBaustein("Mit Herkunft", bausteinVersionId, adminId);
|
||||||
|
|
||||||
|
const abschnitt = await testPrisma.vorlagenAbschnitt.findFirstOrThrow({
|
||||||
|
where: { versionId, bausteinVersionId: { not: null } },
|
||||||
|
include: { bausteinVersion: { include: { baustein: true } } },
|
||||||
|
});
|
||||||
|
expect(abschnitt.bausteinVersion?.baustein.name).toBe("Herkunft");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weist einen Verweis auf einen Entwurf ab", async () => {
|
||||||
|
// Einen Entwurf einzusetzen hieße, beim Veröffentlichen etwas zu kopieren,
|
||||||
|
// das sich danach noch ändert.
|
||||||
|
const { adminId } = await grundbestand();
|
||||||
|
const { versionId: entwurfId } = await bausteinAnlegen({ name: "Noch Entwurf" }, adminId);
|
||||||
|
const { vorlage } = await vorlageAnlegen({ name: "Will einen Entwurf" }, adminId);
|
||||||
|
const versionId = (await entwurfHolenOderErzeugen(vorlage.id, adminId)).id;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
entwurfSpeichern(versionId, {
|
||||||
|
variablen: [],
|
||||||
|
abschnitte: [{ titel: "x", bausteinVersionId: entwurfId, schritte: [] }],
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/veroeffentlichte Baustein-Version/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weist das Veröffentlichen ab, wenn der Baustein archiviert wurde", async () => {
|
||||||
|
const { adminId } = await grundbestand();
|
||||||
|
const { bausteinId, versionId: bausteinVersionId } = await bausteinMitSchritten(
|
||||||
|
"Weggelegt",
|
||||||
|
"Weggelegt",
|
||||||
|
["Eins"],
|
||||||
|
adminId,
|
||||||
|
);
|
||||||
|
const { vorlage } = await vorlageAnlegen({ name: "Mit weggelegtem" }, adminId);
|
||||||
|
const versionId = (await entwurfHolenOderErzeugen(vorlage.id, adminId)).id;
|
||||||
|
await entwurfSpeichern(versionId, {
|
||||||
|
variablen: [],
|
||||||
|
abschnitte: [{ titel: "x", bausteinVersionId, schritte: [] }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Archivieren geht hier nicht: der Baustein steht in einem Entwurf.
|
||||||
|
await expect(bausteinArchivieren(bausteinId, true, adminId)).rejects.toThrow(/Entwurf/);
|
||||||
|
// An der Service-Schicht vorbei archiviert – dann greift die Prüfung beim
|
||||||
|
// Veröffentlichen.
|
||||||
|
await testPrisma.baustein.update({ where: { id: bausteinId }, data: { archiviert: true } });
|
||||||
|
await expect(versionVeroeffentlichen(versionId, null, adminId)).rejects.toThrow(/archiviert/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hält im Protokoll fest, welcher Baustein in welcher Fassung eingesetzt wurde", async () => {
|
||||||
|
const { adminId } = await grundbestand();
|
||||||
|
const { versionId: bausteinVersionId } = await bausteinMitSchritten(
|
||||||
|
"Datensicherung prüfen",
|
||||||
|
"Datensicherung",
|
||||||
|
["Eins"],
|
||||||
|
adminId,
|
||||||
|
);
|
||||||
|
const { versionId } = await vorlageMitBaustein("Mit Protokoll", bausteinVersionId, adminId);
|
||||||
|
|
||||||
|
const eintrag = await testPrisma.protokoll.findFirstOrThrow({
|
||||||
|
where: { entitaet: "VorlagenVersion", entitaetId: versionId, aktion: "Version veröffentlicht" },
|
||||||
|
});
|
||||||
|
expect(eintrag.nachher).toMatchObject({
|
||||||
|
bausteine: [{ name: "Datensicherung prüfen", version: 1 }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Unveränderlichkeit über Baustein-Änderungen hinweg", () => {
|
||||||
|
it("ändert keine veröffentlichte Vorlagenversion, wenn der Baustein neu veröffentlicht wird", async () => {
|
||||||
|
const { adminId } = await grundbestand();
|
||||||
|
const { bausteinId, versionId: ersteBausteinVersion } = await bausteinMitSchritten(
|
||||||
|
"Datensicherung prüfen",
|
||||||
|
"Datensicherung",
|
||||||
|
["Sicherung läuft"],
|
||||||
|
adminId,
|
||||||
|
);
|
||||||
|
const { versionId } = await vorlageMitBaustein("PC Neuinstallation", ersteBausteinVersion, adminId);
|
||||||
|
const vorher = await schritteVon(versionId);
|
||||||
|
|
||||||
|
// Zweite Fassung des Bausteins, inhaltlich ganz anders.
|
||||||
|
const zweiterEntwurf = await bausteinEntwurfHolenOderErzeugen(bausteinId, adminId);
|
||||||
|
await bausteinEntwurfSpeichern(zweiterEntwurf, {
|
||||||
|
titel: "Datensicherung, neu gedacht",
|
||||||
|
schritte: [{ titel: "Ganz anders" }, { titel: "Und noch einer" }],
|
||||||
|
});
|
||||||
|
await bausteinVersionVeroeffentlichen(zweiterEntwurf, "Umgebaut", adminId);
|
||||||
|
|
||||||
|
expect(await schritteVon(versionId)).toEqual(vorher);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lässt einen laufenden Lauf unberührt", async () => {
|
||||||
|
const { adminId } = await grundbestand();
|
||||||
|
const kunde = await testPrisma.kunde.create({
|
||||||
|
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
|
||||||
|
});
|
||||||
|
const { bausteinId, versionId: ersteBausteinVersion } = await bausteinMitSchritten(
|
||||||
|
"Datensicherung prüfen",
|
||||||
|
"Datensicherung",
|
||||||
|
["Sicherung läuft"],
|
||||||
|
adminId,
|
||||||
|
);
|
||||||
|
const { versionId } = await vorlageMitBaustein("PC Neuinstallation", ersteBausteinVersion, adminId);
|
||||||
|
const lauf = await testPrisma.lauf.create({
|
||||||
|
data: { vorlagenVersionId: versionId, titel: "Ein Lauf", kundeId: kunde.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const zweiterEntwurf = await bausteinEntwurfHolenOderErzeugen(bausteinId, adminId);
|
||||||
|
await bausteinEntwurfSpeichern(zweiterEntwurf, {
|
||||||
|
titel: "Anders",
|
||||||
|
schritte: [{ titel: "Ganz anders" }],
|
||||||
|
});
|
||||||
|
await bausteinVersionVeroeffentlichen(zweiterEntwurf, null, adminId);
|
||||||
|
|
||||||
|
const nachher = await testPrisma.lauf.findUniqueOrThrow({
|
||||||
|
where: { id: lauf.id },
|
||||||
|
include: {
|
||||||
|
vorlagenVersion: {
|
||||||
|
include: { abschnitte: { include: { schritte: true }, orderBy: { position: "asc" } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const titel = nachher.vorlagenVersion.abschnitte.flatMap((abschnitt) =>
|
||||||
|
abschnitt.schritte.map((schritt) => schritt.titel),
|
||||||
|
);
|
||||||
|
expect(titel).toContain("Sicherung läuft");
|
||||||
|
expect(titel).not.toContain("Ganz anders");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nimmt die neue Fassung in die nächste veröffentlichte Vorlagenversion", async () => {
|
||||||
|
const { adminId } = await grundbestand();
|
||||||
|
const { bausteinId, versionId: ersteBausteinVersion } = await bausteinMitSchritten(
|
||||||
|
"Datensicherung prüfen",
|
||||||
|
"Datensicherung",
|
||||||
|
["Sicherung läuft"],
|
||||||
|
adminId,
|
||||||
|
);
|
||||||
|
const { vorlageId } = await vorlageMitBaustein("PC Neuinstallation", ersteBausteinVersion, adminId);
|
||||||
|
|
||||||
|
const zweiterEntwurf = await bausteinEntwurfHolenOderErzeugen(bausteinId, adminId);
|
||||||
|
await bausteinEntwurfSpeichern(zweiterEntwurf, {
|
||||||
|
titel: "Datensicherung",
|
||||||
|
schritte: [{ titel: "Sicherung läuft" }, { titel: "Rücksicherung getestet" }],
|
||||||
|
});
|
||||||
|
await bausteinVersionVeroeffentlichen(zweiterEntwurf, null, adminId);
|
||||||
|
const neueFassung = await testPrisma.bausteinVersion.findFirstOrThrow({
|
||||||
|
where: { bausteinId, status: VersionStatus.VEROEFFENTLICHT },
|
||||||
|
orderBy: { version: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Neue Vorlagenversion, die auf die neue Fassung zeigt.
|
||||||
|
const zweiteVorlagenVersion = (await entwurfHolenOderErzeugen(vorlageId, adminId)).id;
|
||||||
|
await entwurfSpeichern(zweiteVorlagenVersion, {
|
||||||
|
variablen: [],
|
||||||
|
abschnitte: [
|
||||||
|
{ titel: "Eigener Abschnitt", schritte: [{ titel: "Eigener Schritt" }] },
|
||||||
|
{ titel: "x", bausteinVersionId: neueFassung.id, schritte: [] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await versionVeroeffentlichen(zweiteVorlagenVersion, null, adminId);
|
||||||
|
|
||||||
|
expect(await schritteVon(zweiteVorlagenVersion)).toEqual([
|
||||||
|
"# Eigener Abschnitt",
|
||||||
|
"Eigener Schritt",
|
||||||
|
"# Datensicherung",
|
||||||
|
"Sicherung läuft",
|
||||||
|
"Rücksicherung getestet",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Löschen und Verwendungen", () => {
|
||||||
|
it("lässt eine Baustein-Version nicht löschen, auf die eine Vorlage zeigt", async () => {
|
||||||
|
const { adminId } = await grundbestand();
|
||||||
|
const { versionId: bausteinVersionId } = await bausteinMitSchritten(
|
||||||
|
"Benutzt",
|
||||||
|
"Benutzt",
|
||||||
|
["Eins"],
|
||||||
|
adminId,
|
||||||
|
);
|
||||||
|
await vorlageMitBaustein("Benutzt ihn", bausteinVersionId, adminId);
|
||||||
|
|
||||||
|
await expect(testPrisma.bausteinVersion.delete({ where: { id: bausteinVersionId } })).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nennt, wo ein Baustein steckt – mit Vorlage, Version und Fassung", async () => {
|
||||||
|
const { adminId } = await grundbestand();
|
||||||
|
const { bausteinId, versionId: bausteinVersionId } = await bausteinMitSchritten(
|
||||||
|
"Überall",
|
||||||
|
"Überall",
|
||||||
|
["Eins"],
|
||||||
|
adminId,
|
||||||
|
);
|
||||||
|
await vorlageMitBaustein("Vorlage A", bausteinVersionId, adminId);
|
||||||
|
await vorlageMitBaustein("Vorlage B", bausteinVersionId, adminId);
|
||||||
|
|
||||||
|
const verwendungen = await bausteinVerwendungen(bausteinId);
|
||||||
|
expect(verwendungen).toHaveLength(2);
|
||||||
|
expect(verwendungen.map((eintrag) => eintrag.vorlageName)).toEqual(["Vorlage A", "Vorlage B"]);
|
||||||
|
expect(verwendungen[0]).toMatchObject({
|
||||||
|
vorlagenVersion: 1,
|
||||||
|
status: VersionStatus.VEROEFFENTLICHT,
|
||||||
|
bausteinVersion: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("zählt in der Liste Verwendungen und Schritte", async () => {
|
||||||
|
const { adminId } = await grundbestand();
|
||||||
|
const { versionId: bausteinVersionId } = await bausteinMitSchritten(
|
||||||
|
"Gezählt",
|
||||||
|
"Gezählt",
|
||||||
|
["Eins", "Zwei"],
|
||||||
|
adminId,
|
||||||
|
);
|
||||||
|
await vorlageMitBaustein("Benutzt ihn", bausteinVersionId, adminId);
|
||||||
|
|
||||||
|
const liste = await bausteinListe();
|
||||||
|
expect(liste).toHaveLength(1);
|
||||||
|
expect(liste[0]).toMatchObject({
|
||||||
|
name: "Gezählt",
|
||||||
|
veroeffentlichteVersion: 1,
|
||||||
|
hatEntwurf: false,
|
||||||
|
anzahlSchritte: 2,
|
||||||
|
verwendungen: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bietet zum Einsetzen nur veröffentlichte Fassungen nicht archivierter Bausteine", async () => {
|
||||||
|
const { adminId } = await grundbestand();
|
||||||
|
await bausteinMitSchritten("Brauchbar", "Brauchbar", ["Eins"], adminId);
|
||||||
|
await bausteinAnlegen({ name: "Nur Entwurf" }, adminId);
|
||||||
|
const { bausteinId } = await bausteinMitSchritten("Weggelegt", "Weggelegt", ["Eins"], adminId);
|
||||||
|
await bausteinArchivieren(bausteinId, true, adminId);
|
||||||
|
|
||||||
|
const einsetzbar = await einsetzbareBausteine();
|
||||||
|
expect(einsetzbar.map((eintrag) => eintrag.name)).toEqual(["Brauchbar"]);
|
||||||
|
expect(einsetzbar[0]).toMatchObject({ version: 1, anzahlSchritte: 1, titel: "Brauchbar" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Austausch", () => {
|
||||||
|
it("exportiert die aufgelösten Schritte und keinen Verweis", async () => {
|
||||||
|
// Eine Kennung aus dieser Installation bedeutet in einer anderen nichts.
|
||||||
|
const { adminId } = await grundbestand();
|
||||||
|
const { versionId: bausteinVersionId } = await bausteinMitSchritten(
|
||||||
|
"Datensicherung prüfen",
|
||||||
|
"Datensicherung",
|
||||||
|
["Sicherung läuft"],
|
||||||
|
adminId,
|
||||||
|
);
|
||||||
|
const { versionId } = await vorlageMitBaustein("Zum Ausfuhren", bausteinVersionId, adminId);
|
||||||
|
|
||||||
|
const austausch = await vorlageExportieren(versionId);
|
||||||
|
expect(JSON.stringify(austausch)).not.toContain(bausteinVersionId);
|
||||||
|
const abschnitte = austausch.struktur.abschnitte.map((abschnitt) => ({
|
||||||
|
titel: abschnitt.titel,
|
||||||
|
schritte: abschnitt.schritte.map((schritt) => schritt.titel),
|
||||||
|
}));
|
||||||
|
expect(abschnitte).toEqual([
|
||||||
|
{ titel: "Eigener Abschnitt", schritte: ["Eigener Schritt"] },
|
||||||
|
{ titel: "Datensicherung", schritte: ["Sicherung läuft"] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("übernimmt beim Import keinen mitgeschickten Verweis", async () => {
|
||||||
|
const { adminId } = await grundbestand();
|
||||||
|
const { versionId: bausteinVersionId } = await bausteinMitSchritten(
|
||||||
|
"Datensicherung prüfen",
|
||||||
|
"Datensicherung",
|
||||||
|
["Sicherung läuft"],
|
||||||
|
adminId,
|
||||||
|
);
|
||||||
|
const { versionId } = await vorlageMitBaustein("Quelle", bausteinVersionId, adminId);
|
||||||
|
const austausch = await vorlageExportieren(versionId);
|
||||||
|
|
||||||
|
const { versionId: kopie } = await vorlageImportieren(
|
||||||
|
{
|
||||||
|
...austausch,
|
||||||
|
name: "Kopie",
|
||||||
|
struktur: {
|
||||||
|
...austausch.struktur,
|
||||||
|
abschnitte: austausch.struktur.abschnitte.map((abschnitt) => ({
|
||||||
|
...abschnitt,
|
||||||
|
bausteinVersionId,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
adminId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const abschnitte = await testPrisma.vorlagenAbschnitt.findMany({ where: { versionId: kopie } });
|
||||||
|
expect(abschnitte.every((abschnitt) => abschnitt.bausteinVersionId === null)).toBe(true);
|
||||||
|
// Die Schritte sind trotzdem alle da – sie stehen in der Datei.
|
||||||
|
expect(await schritteVon(kopie)).toEqual([
|
||||||
|
"# Eigener Abschnitt",
|
||||||
|
"Eigener Schritt",
|
||||||
|
"# Datensicherung",
|
||||||
|
"Sicherung läuft",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wirft eigene Fehlerklassen, damit die Actions sie unterscheiden können", async () => {
|
||||||
|
const { adminId } = await grundbestand();
|
||||||
|
await expect(bausteinAnlegen({ name: " " }, adminId)).rejects.toThrow(BausteinFehler);
|
||||||
|
await expect(versionVeroeffentlichen("gibt-es-nicht", null, adminId)).rejects.toThrow(VorlagenFehler);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -51,6 +51,9 @@ describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
|
|||||||
|
|
||||||
it("hat die Trigger, die eine veröffentlichte Version schützen", async () => {
|
it("hat die Trigger, die eine veröffentlichte Version schützen", async () => {
|
||||||
expect(await namen(`SELECT tgname AS name FROM pg_trigger WHERE NOT tgisinternal`)).toEqual([
|
expect(await namen(`SELECT tgname AS name FROM pg_trigger WHERE NOT tgisinternal`)).toEqual([
|
||||||
|
"BausteinSchritt_unveraenderlich",
|
||||||
|
"BausteinVersion_status_einbahn",
|
||||||
|
"VorlagenAbschnitt_baustein_veroeffentlicht",
|
||||||
"VorlagenAbschnitt_unveraenderlich",
|
"VorlagenAbschnitt_unveraenderlich",
|
||||||
"VorlagenSchritt_unveraenderlich",
|
"VorlagenSchritt_unveraenderlich",
|
||||||
"VorlagenVariable_unveraenderlich",
|
"VorlagenVariable_unveraenderlich",
|
||||||
@@ -64,6 +67,7 @@ describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
|
|||||||
`SELECT indexname AS name FROM pg_indexes WHERE schemaname='public' AND indexdef LIKE '%WHERE%'`,
|
`SELECT indexname AS name FROM pg_indexes WHERE schemaname='public' AND indexdef LIKE '%WHERE%'`,
|
||||||
),
|
),
|
||||||
).toEqual([
|
).toEqual([
|
||||||
|
"BausteinVersion_ein_entwurf",
|
||||||
"Kunde_eigene_organisation_einmalig",
|
"Kunde_eigene_organisation_einmalig",
|
||||||
"Vorlage_seedSchluessel_einmalig",
|
"Vorlage_seedSchluessel_einmalig",
|
||||||
"VorlagenVersion_ein_entwurf",
|
"VorlagenVersion_ein_entwurf",
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import {
|
|||||||
verwendetePlatzhalter,
|
verwendetePlatzhalter,
|
||||||
type EditorAufbau,
|
type EditorAufbau,
|
||||||
type EditorVariable,
|
type EditorVariable,
|
||||||
|
bausteineAnreichern,
|
||||||
|
neuerBausteinAbschnitt,
|
||||||
} from "@/lib/editor-aufbau";
|
} from "@/lib/editor-aufbau";
|
||||||
import { EINGEBAUTE_PLATZHALTER } from "@/lib/platzhalter";
|
import { EINGEBAUTE_PLATZHALTER } from "@/lib/platzhalter";
|
||||||
import { StrukturSchema } from "@/lib/vorlagen-format";
|
import { StrukturSchema } from "@/lib/vorlagen-format";
|
||||||
@@ -254,3 +256,70 @@ describe("Beispielwerte der Vorschau", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Bausteine im Editor-Aufbau", () => {
|
||||||
|
const BAUSTEIN = {
|
||||||
|
bausteinVersionId: "bv-1",
|
||||||
|
name: "Datensicherung prüfen",
|
||||||
|
titel: "Datensicherung",
|
||||||
|
version: 3,
|
||||||
|
anzahlSchritte: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("schickt die Platzhalter-Schritte eines Baustein-Abschnitts nicht mit", () => {
|
||||||
|
/*
|
||||||
|
* Der Fehler, den erst das Durchklicken des gebauten Stands gezeigt hat
|
||||||
|
* (#030): die Schritte im Zustand sind nur da, damit die Anzahl in der Karte
|
||||||
|
* stimmt — sie haben keinen Titel. Mitgeschickt wies `SchrittSchema` den
|
||||||
|
* ganzen Entwurf ab, und das Speichern scheiterte.
|
||||||
|
*/
|
||||||
|
const abschnitt = neuerBausteinAbschnitt(BAUSTEIN);
|
||||||
|
expect(abschnitt.schritte).toHaveLength(2);
|
||||||
|
expect(abschnitt.schritte.every((schritt) => schritt.titel === "")).toBe(true);
|
||||||
|
|
||||||
|
const struktur = alsStruktur({ variablen: [], abschnitte: [abschnitt] });
|
||||||
|
expect(struktur.abschnitte[0]?.schritte).toEqual([]);
|
||||||
|
expect(struktur.abschnitte[0]?.bausteinVersionId).toBe("bv-1");
|
||||||
|
// Und das Ergebnis muss durch das Schema gehen – genau das tat es vorher nicht.
|
||||||
|
expect(() => StrukturSchema.parse(struktur)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nimmt den Verweis mit und gibt ihn zurück", () => {
|
||||||
|
const struktur = StrukturSchema.parse({
|
||||||
|
variablen: [],
|
||||||
|
abschnitte: [
|
||||||
|
{ titel: "Eigen", schritte: [{ titel: "Eins" }] },
|
||||||
|
{ titel: "x", bausteinVersionId: "bv-1", schritte: [] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const aufbau = alsEditorAufbau(struktur);
|
||||||
|
expect(aufbau.abschnitte[0]?.baustein).toBeUndefined();
|
||||||
|
expect(aufbau.abschnitte[1]?.baustein?.versionId).toBe("bv-1");
|
||||||
|
expect(alsStruktur(aufbau).abschnitte[1]?.bausteinVersionId).toBe("bv-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trägt Name und Fassung nach und benennt einen Verweis, der nicht mehr gilt", () => {
|
||||||
|
const struktur = StrukturSchema.parse({
|
||||||
|
variablen: [],
|
||||||
|
abschnitte: [
|
||||||
|
{ titel: "a", bausteinVersionId: "bv-1", schritte: [] },
|
||||||
|
{ titel: "b", bausteinVersionId: "bv-weg", schritte: [] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const aufbau = bausteineAnreichern(alsEditorAufbau(struktur), [BAUSTEIN]);
|
||||||
|
expect(aufbau.abschnitte[0]?.baustein).toEqual({
|
||||||
|
versionId: "bv-1",
|
||||||
|
name: "Datensicherung prüfen",
|
||||||
|
version: 3,
|
||||||
|
});
|
||||||
|
// Ein Verweis, dessen Baustein nicht mehr einsetzbar ist, behält seine
|
||||||
|
// Kennung – verschwinden darf er nicht, sonst wäre er beim nächsten
|
||||||
|
// Speichern still weg.
|
||||||
|
expect(aufbau.abschnitte[1]?.baustein).toEqual({
|
||||||
|
versionId: "bv-weg",
|
||||||
|
name: "(nicht mehr verfügbar)",
|
||||||
|
version: 0,
|
||||||
|
});
|
||||||
|
expect(alsStruktur(aufbau).abschnitte[1]?.bausteinVersionId).toBe("bv-weg");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user