feat(bausteine): Abschnitte, die mehrere Vorlagen teilen
"Datensicherung pruefen" steht in vier der sieben mitgelieferten Vorlagen, wortgleich. Wer den Ablauf aendert, aendert ihn viermal -- oder dreimal und vergisst den vierten. Entschieden wie im Issue vorgeschlagen: ein Entwurf **verweist** auf eine veroeffentlichte Baustein-Fassung, und beim Veroeffentlichen der Vorlage setzt der Dienst deren Schritte als tiefe Kopie ein. Danach sieht ein Lauf weiterhin konkrete Schritte, und Schrittergebnis, Druckansicht und Archiv muessen nichts von Bausteinen wissen. Die Alternative -- Laeufe verweisen zur Laufzeit -- haette die Unveraenderlichkeit an einer zweiten Stelle durchsetzen muessen. Eine neue Fassung aendert damit keine veroeffentlichte Vorlagenversion und keinen laufenden Lauf. Sie wirkt in der naechsten veroeffentlichten Version; die Oberflaeche sagt das an zwei Stellen, weil man sonst Durchschlag erwartet. Vier Garantien liegen in der Datenbank, nicht nur im Dienst: hoechstens ein Entwurf je Baustein, eine veroeffentlichte Fassung ist unveraenderlich, der Weg zurueck zum Entwurf ist versperrt, und ein Abschnitt kann nur auf eine veroeffentlichte Fassung verweisen. Der Titel kommt aus dem Baustein, nicht aus dem Abschnitt der Vorlage: sonst muesste er in jeder Vorlage einzeln gepflegt werden, und zwei Vorlagen zeigten denselben Baustein unter verschiedenen Namen. Ein Export traegt die aufgeloesten Schritte, keinen Verweis -- eine Kennung aus dieser Installation bedeutet in einer anderen nichts. Das Durchklicken des gebauten Stands fand einen Fehler, den die Suite nicht gesehen haette: die Platzhalter-Schritte eines Baustein-Abschnitts haben keinen Titel, wurden aber mitgeschickt, und SchrittSchema wies den ganzen Entwurf ab. Behoben, mit Test und Gegenprobe. Der ganze Weg danach am gebauten Stand nachgemessen: Baustein anlegen, veroeffentlichen, einsetzen, Vorlage veroeffentlichen -- die Schritte stehen drin, mit Herkunft und Protokolleintrag. Closes #50
This commit is contained in:
@@ -26,6 +26,14 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **Wiederverwendbare Bausteine.** Ein Abschnitt, den mehrere Vorlagen teilen und der an einer
|
||||
Stelle gepflegt wird — „Datensicherung prüfen“ steht in vier der sieben mitgelieferten Vorlagen,
|
||||
wortgleich. Versioniert nach denselben Regeln wie eine Vorlage: höchstens ein Entwurf, eine
|
||||
veröffentlichte Fassung wird nie verändert. Eingesetzt wird ein Baustein im Editor; beim
|
||||
**Veröffentlichen** einer Vorlage setzt der Dienst seine Schritte als tiefe Kopie ein. Damit
|
||||
ändert eine neue Baustein-Fassung **keine** veröffentlichte Vorlagenversion und **keinen**
|
||||
laufenden Lauf — sie wirkt erst in der nächsten veröffentlichten Version. Auf der Seite eines
|
||||
Bausteins steht, in welchen Vorlagen er vorkommt und welche Fassung dort eingesetzt ist (#030)
|
||||
- **Das Protokoll ist ansehbar**, unter Verwaltung → Protokoll. Geschrieben wurde es seit dem
|
||||
Datenmodell, ansehen konnte es niemand: wer wissen wollte, wer wann eine Rolle geändert hat,
|
||||
brauchte `psql`. Jetzt mit Volltext über Aktion und Lauftitel, Filter nach Urheber und Zeitraum,
|
||||
|
||||
@@ -190,6 +190,55 @@ und für `{{generiertes_passwort}}` entsteht **kein** Passwort — dort steht
|
||||
Nutz sie vor dem Veröffentlichen, besonders bei Befehlen: erst hier sieht man, ob der fertige Befehl
|
||||
so aussieht, wie er aussehen soll.
|
||||
|
||||
## Bausteine: ein Abschnitt für mehrere Vorlagen
|
||||
|
||||
„Datensicherung prüfen“ steht in vier der sieben mitgelieferten Vorlagen, wortgleich. Wer den Ablauf
|
||||
ändert, ändert ihn viermal — oder dreimal und vergisst den vierten. Ein **Baustein** ist ein
|
||||
Abschnitt, den mehrere Vorlagen teilen und der an einer Stelle gepflegt wird.
|
||||
|
||||
Die Liste steht hinter dem Knopf **Bausteine** oben auf der Vorlagenseite. Ein Baustein wird dort
|
||||
angelegt (nur ein Name), bekommt dann einen Titel und seine Schritte, und wird **veröffentlicht** —
|
||||
genau wie eine Vorlage, mit denselben Regeln: höchstens ein Entwurf, und eine veröffentlichte
|
||||
Fassung wird nie wieder verändert.
|
||||
|
||||
### Einsetzen
|
||||
|
||||
Im Editor einer Vorlage steht unten neben **Abschnitt hinzufügen** das Feld **Baustein einsetzen**.
|
||||
Angeboten werden nur veröffentlichte Fassungen nicht archivierter Bausteine. Der Abschnitt erscheint
|
||||
dann als Einheit: mit dem Kennzeichen „Baustein“, dem Namen der Quelle und der Zahl seiner Schritte.
|
||||
|
||||
**Titel und Schritte lassen sich dort nicht ändern.** Beides kommt beim Veröffentlichen aus dem
|
||||
Baustein — ein Feld, das man ausfüllen kann und das danach überschrieben wird, wäre eine Falle.
|
||||
Geändert wird am Baustein.
|
||||
|
||||
### Was eine Änderung am Baustein bewirkt — und was nicht
|
||||
|
||||
Das ist der Punkt, den man einmal verstanden haben muss:
|
||||
|
||||
- **Schon veröffentlichte Vorlagenversionen ändern sich nicht.** Sie tragen beim Veröffentlichen
|
||||
eingesetzte **Kopien** der Schritte. Ein Lauf von vor zwei Jahren zeigt genau das, was damals
|
||||
abgearbeitet wurde — daran ändert kein Baustein etwas.
|
||||
- **Laufende Arbeiten ändern sich nicht.** Aus demselben Grund.
|
||||
- **Die neue Fassung wirkt in der nächsten veröffentlichten Version** einer Vorlage. Wer sie dort
|
||||
haben will, legt einen neuen Entwurf an und veröffentlicht ihn.
|
||||
|
||||
Auf der Seite eines Bausteins steht unter **Wo er steckt**, in welchen Vorlagen er vorkommt und
|
||||
welche **Fassung** dort eingesetzt ist. Vor einer Änderung ist das die Liste, die man lesen will.
|
||||
|
||||
### Archivieren
|
||||
|
||||
Gelöscht wird ein Baustein nie: veröffentlichte Vorlagenversionen tragen seine Herkunft. Archiviert
|
||||
lässt er sich nicht mehr neu einsetzen, bleibt aber in jeder Vorlage lesbar, die ihn schon benutzt.
|
||||
Steht er noch in einem **Entwurf**, weist Laufzettel das Archivieren ab — diese Vorlage ließe sich
|
||||
danach nicht mehr veröffentlichen.
|
||||
|
||||
### In einer Austauschdatei steht kein Baustein
|
||||
|
||||
Ein Export trägt die **aufgelösten** Schritte, keinen Verweis. Eine Kennung aus dieser Installation
|
||||
bedeutet in einer anderen nichts, und ein Verweis auf einen Baustein, den es dort nicht gibt, wäre
|
||||
eine Vorlage, die sich nicht veröffentlichen lässt. Wer einen Baustein mitgeben will, exportiert die
|
||||
Vorlage und legt den Baustein in der anderen Installation neu an.
|
||||
|
||||
## Veröffentlichen
|
||||
|
||||
Im Abschnitt **Veröffentlichen** trägst du eine **Änderungsnotiz (optional)** ein — ein Satz, was
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# #030 M17 – Wiederverwendbare Bausteine
|
||||
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Im Tracker:** [#50](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/50)
|
||||
**Meilenstein:** v0.2.0
|
||||
**Labels:** phase-2, backend, frontend
|
||||
**Geschätzt:** 12 h **Tatsächlich:** –
|
||||
**Geschätzt:** 12 h **Tatsächlich:** 2 h 25 min
|
||||
|
||||
## Ziel
|
||||
|
||||
@@ -20,7 +20,7 @@ wurden. Ein Baustein, der sich ändert, darf das nicht antasten.
|
||||
|
||||
## Zu entscheiden
|
||||
|
||||
- [ ] **Baustein mit eigener Version, beim Veröffentlichen tief kopiert** — Empfehlung. Ein
|
||||
- [x] **Baustein mit eigener Version, beim Veröffentlichen tief kopiert** — Empfehlung. Ein
|
||||
Entwurf **verweist** auf einen Baustein; beim Veröffentlichen löst der Dienst den Verweis in
|
||||
echte Abschnitte und Schritte auf. Damit bleibt alles, was danach kommt, unberührt: `Lauf`,
|
||||
`Schrittergebnis`, Druckansicht und Archiv sehen weiterhin konkrete Schritte und müssen nichts
|
||||
@@ -28,32 +28,55 @@ wurden. Ein Baustein, der sich ändert, darf das nicht antasten.
|
||||
Die Alternative — Läufe verweisen zur Laufzeit auf den Baustein — hieße, dass die Unveränder-
|
||||
lichkeit an einer zweiten Stelle durchgesetzt werden muss, und dass jede Abfrage über Schritte
|
||||
zwei Wege kennen muss. Das ist der Preis für eine Bequemlichkeit, die niemand verlangt hat.
|
||||
- [ ] **Was „Baustein aktualisieren“ für laufende Arbeiten bedeutet:** nichts. Eine neue
|
||||
- [x] **Was „Baustein aktualisieren“ für laufende Arbeiten bedeutet:** nichts. Eine neue
|
||||
Baustein-Version wirkt erst in der **nächsten** veröffentlichten Vorlagenversion. Das muss die
|
||||
Oberfläche sagen, sonst erwartet man Durchschlag.
|
||||
|
||||
## Aufgaben
|
||||
|
||||
- [ ] Modelle `Baustein` und `BausteinVersion` mit denselben Regeln wie bei Vorlagen: höchstens ein
|
||||
- [x] Modelle `Baustein` und `BausteinVersion` mit denselben Regeln wie bei Vorlagen: höchstens ein
|
||||
Entwurf, veröffentlichte Version unveränderlich (Trigger, nicht nur Dienst)
|
||||
- [ ] `VorlagenAbschnitt` kann statt eigener Schritte einen Baustein nennen — nur im **Entwurf**
|
||||
- [ ] Veröffentlichen löst auf: tiefe Kopie der Baustein-Abschnitte und -Schritte in die Version,
|
||||
- [x] `VorlagenAbschnitt` kann statt eigener Schritte einen Baustein nennen — nur im **Entwurf**
|
||||
- [x] Veröffentlichen löst auf: tiefe Kopie der Baustein-Abschnitte und -Schritte in die Version,
|
||||
mit Vermerk, aus welchem Baustein und welcher Version sie stammen
|
||||
- [ ] Editor: Baustein einfügen, als Einheit anzeigen, nicht Schritt für Schritt bearbeitbar; ein
|
||||
- [x] Editor: Baustein einfügen, als Einheit anzeigen, nicht Schritt für Schritt bearbeitbar; ein
|
||||
Hinweis nennt Baustein und Version
|
||||
- [ ] Eigene Verwaltung für Bausteine, Recht `vorlagenBearbeiten` (wer Vorlagen pflegt, pflegt
|
||||
- [x] Eigene Verwaltung für Bausteine, Recht `vorlagenBearbeiten` (wer Vorlagen pflegt, pflegt
|
||||
Bausteine)
|
||||
- [ ] „Wo wird dieser Baustein benutzt“ — vor dem Ändern will man das wissen
|
||||
- [ ] Anleitung Kapitel 04
|
||||
- [x] „Wo wird dieser Baustein benutzt“ — vor dem Ändern will man das wissen
|
||||
- [x] Anleitung Kapitel 04
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Test: eine neue Baustein-Version ändert **keine** veröffentlichte Vorlagenversion und **keinen**
|
||||
- [x] Test: eine neue Baustein-Version ändert **keine** veröffentlichte Vorlagenversion und **keinen**
|
||||
laufenden Lauf
|
||||
- [ ] Test: die Druckansicht eines archivierten Laufs ist nach einer Baustein-Änderung Zeichen für
|
||||
- [x] Test: die Druckansicht eines archivierten Laufs ist nach einer Baustein-Änderung Zeichen für
|
||||
Zeichen dieselbe
|
||||
- [ ] Test: die nächste veröffentlichte Vorlagenversion enthält die neue Baustein-Version
|
||||
- [ ] Test: ein Baustein lässt sich nicht löschen, solange eine Vorlage ihn im Entwurf nennt
|
||||
- [ ] Test: Export und Import einer Vorlage mit Baustein ergeben eine inhaltsgleiche Vorlage — die
|
||||
- [x] Test: die nächste veröffentlichte Vorlagenversion enthält die neue Baustein-Version
|
||||
- [x] Test: ein Baustein lässt sich nicht löschen, solange eine Vorlage ihn im Entwurf nennt
|
||||
- [x] Test: Export und Import einer Vorlage mit Baustein ergeben eine inhaltsgleiche Vorlage — die
|
||||
Datei trägt die aufgelösten Schritte, keinen Verweis auf einen Baustein, den die andere
|
||||
Installation nicht hat
|
||||
|
||||
## Beim Umsetzen dazugekommen
|
||||
|
||||
- **Ein Baustein ist genau ein Abschnitt**, nicht eine Gruppe davon. Das stand so im Auftrag
|
||||
(„Abschnitte, die mehrere Vorlagen teilen“) und spart eine Verschachtelungsebene, die niemand
|
||||
gebraucht hätte.
|
||||
- **Der Verweis bleibt nach dem Veröffentlichen stehen** und ist dann nur noch Herkunft. Welche der
|
||||
beiden Lesarten gilt, sagt der Status der Version — ein zweites Feld dafür wäre eine zweite
|
||||
Wahrheit. Die Seite eines Bausteins liest daraus „Wo er steckt“, mit der Fassung je Vorlage.
|
||||
- **Der Titel kommt aus dem Baustein**, nicht aus dem Abschnitt der Vorlage. Sonst müsste man ihn in
|
||||
jeder Vorlage einzeln pflegen, und zwei Vorlagen zeigten denselben Baustein unter verschiedenen
|
||||
Namen.
|
||||
- **Vier Garantien liegen in der Datenbank**, nicht nur im Dienst: höchstens ein Entwurf (partieller
|
||||
Index), eine veröffentlichte Fassung ist unveränderlich (Trigger), der Weg zurück zum Entwurf ist
|
||||
versperrt (Trigger), und ein Abschnitt kann nur auf eine **veröffentlichte** Fassung verweisen
|
||||
(Trigger). Beim Aufräumen der Testdaten hat der zweite sofort zugeschlagen — gut so.
|
||||
- **Das Durchklicken des gebauten Stands fand einen Fehler**, den die Suite nicht gesehen hätte:
|
||||
`neuerBausteinAbschnitt` legt Platzhalter-Schritte **ohne Titel** an, damit die Anzahl in der Karte
|
||||
stimmt. `alsStruktur` schickte sie mit, und `SchrittSchema` wies den ganzen Entwurf ab — das
|
||||
Speichern scheiterte. Behoben, mit Test und Gegenprobe.
|
||||
- Dabei habe ich **zweimal mein eigenes Prüfskript** für den Fehler gehalten: einmal griff ein zu
|
||||
weiter `role=status`-Selektor den falschen Text, einmal eine Prüfung nach dem Neuladen. Die
|
||||
Datenbank hat beides geklärt — sie ist der einzige Zeuge, der nicht lügt.
|
||||
|
||||
@@ -34,10 +34,10 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
|
||||
| #027 | [Die Editor-Abläufe lassen ihre Vorlagen stehen](027-e2e-reste-vorlagen.md) | offen | v0.2.0 | 1 h | – |
|
||||
| #028 | [M15 – Quellenarten und Microsoft-Graph-Quelle](028-graph-quelle.md) | offen | v0.2.0 | 10 h | – |
|
||||
| #029 | [M16 – SCIM-Endpunkt als Alternative zum Abgleich](029-scim-endpunkt.md) | offen | v0.2.0 | 12 h | – |
|
||||
| #030 | [M17 – Wiederverwendbare Bausteine](030-bausteine.md) | offen | v0.2.0 | 12 h | – |
|
||||
| #030 | [M17 – Wiederverwendbare Bausteine](030-bausteine.md) | erledigt | v0.2.0 | 12 h | 2 h 25 min |
|
||||
| #031 | [M18 – Abteilungsspezifische Zusatzabschnitte](031-abteilungsabschnitte.md) | offen | v0.2.0 | 8 h | – |
|
||||
| #032 | [M19 – Anhänge und Fotos an Schritten](032-anhaenge.md) | offen | v0.2.0 | 10 h | – |
|
||||
| #033 | [M20 – Live-Aktualisierung per SSE statt Polling](033-sse-live.md) | offen | v0.2.0 | 6 h | – |
|
||||
| #034 | [M21 – Benachrichtigungen per Mail und Webhook](034-benachrichtigungen.md) | offen | v0.2.0 | 10 h | – |
|
||||
| #035 | [M22 – Mitarbeiter-Selbstauskunft](035-selbstauskunft.md) | offen | v0.2.0 | 8 h | – |
|
||||
| | **Summe** | | | **229 h** | **41 h 20 min** |
|
||||
| | **Summe** | | | **229 h** | **43 h 45 min** |
|
||||
|
||||
@@ -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();
|
||||
@@ -468,10 +468,80 @@ model VorlagenAbschnitt {
|
||||
versionId String
|
||||
titel String
|
||||
position Int @default(0)
|
||||
/// Verweis auf einen wiederverwendbaren Baustein (#030).
|
||||
///
|
||||
/// In einem **Entwurf** heisst das: beim Veroeffentlichen hier die Schritte des
|
||||
/// Bausteins einsetzen. In einer **veroeffentlichten** Version heisst dasselbe
|
||||
/// Feld: diese Schritte stammen von dort — es ist dann nur noch Herkunft.
|
||||
/// Welche der beiden Lesarten gilt, sagt der Status der Version; ein zweites
|
||||
/// Feld dafuer waere eine zweite Wahrheit.
|
||||
bausteinVersionId String?
|
||||
|
||||
version VorlagenVersion @relation(fields: [versionId], references: [id], onDelete: Cascade)
|
||||
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])
|
||||
}
|
||||
|
||||
|
||||
@@ -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 { FileText, Pencil, Plus } from "lucide-react";
|
||||
import { Blocks, FileText, Pencil, Plus } from "lucide-react";
|
||||
import { VorlageAnlegen } from "@/components/vorlage-anlegen";
|
||||
import { VorlageImportieren } from "@/components/vorlage-importieren";
|
||||
import { darf } from "@/server/auth/rollen";
|
||||
@@ -129,7 +129,16 @@ export default async function VorlagenSeite({
|
||||
|
||||
return (
|
||||
<>
|
||||
<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">
|
||||
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
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Editor } from "@/components/vorlagen/editor";
|
||||
import { VersionStatus } from "@/generated/prisma/enums";
|
||||
import { darf } from "@/server/auth/rollen";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { einsetzbareBausteine } from "@/server/queries/bausteine";
|
||||
import { versionMitAufbau } from "@/server/queries/vorlagen";
|
||||
|
||||
/**
|
||||
@@ -26,6 +27,13 @@ export default async function VersionSeite({ params }: { params: Promise<{ versi
|
||||
|
||||
const darfBearbeiten = darf(benutzer.rolle, "vorlagenBearbeiten");
|
||||
const veroeffentlicht = version.status === VersionStatus.VEROEFFENTLICHT;
|
||||
/*
|
||||
* Die einsetzbaren Bausteine braucht nur ein Entwurf, den jemand aendern darf
|
||||
* (#030). Eine veroeffentlichte Version bekommt sie trotzdem — sonst stuenden
|
||||
* bei ihren Baustein-Abschnitten statt des Namens nur „nicht mehr verfuegbar“,
|
||||
* und das waere eine Falschaussage ueber die Herkunft.
|
||||
*/
|
||||
const bausteine = await einsetzbareBausteine();
|
||||
|
||||
return (
|
||||
<Editor
|
||||
@@ -41,6 +49,7 @@ export default async function VersionSeite({ params }: { params: Promise<{ versi
|
||||
}}
|
||||
lesend={veroeffentlicht || !darfBearbeiten}
|
||||
darfBearbeiten={darfBearbeiten}
|
||||
bausteine={bausteine}
|
||||
// Auf dem Server formatiert: im Browser waere es eine zweite Uhrzeit und
|
||||
// damit ein Unterschied zwischen Vorrendern und Hydrieren.
|
||||
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";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Blocks } from "lucide-react";
|
||||
import { KlappKarte, LoeschKnopf, TextKnopf, Textfeld } from "@/components/vorlagen/editor-felder";
|
||||
import { SortierBereich, SortierZeile, VerschiebeKnoepfe } from "@/components/vorlagen/editor-sortieren";
|
||||
import { SchrittKarte } from "@/components/vorlagen/editor-schritt";
|
||||
@@ -57,6 +58,7 @@ export function AbschnittKarte({
|
||||
const [offen, setzeOffen] = useState(true);
|
||||
const voll = abschnitt.schritte.length >= GRENZEN.schritteJeAbschnitt;
|
||||
const anzahlSchritte = abschnitt.schritte.length;
|
||||
const baustein = abschnitt.baustein;
|
||||
|
||||
return (
|
||||
<KlappKarte
|
||||
@@ -75,6 +77,12 @@ export function AbschnittKarte({
|
||||
<span className="shrink-0 text-xs font-normal text-nebentext">
|
||||
{anzahlSchritte} {anzahlSchritte === 1 ? "Schritt" : "Schritte"}
|
||||
</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={
|
||||
@@ -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
|
||||
beschriftung="Titel des Abschnitts"
|
||||
wert={abschnitt.titel}
|
||||
@@ -104,9 +137,9 @@ export function AbschnittKarte({
|
||||
lesend={lesend}
|
||||
vorschlag="Vorbereitung"
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{anzahlSchritte === 0 ? (
|
||||
{baustein ? null : anzahlSchritte === 0 ? (
|
||||
<p className="text-sm text-nebentext">Noch kein Schritt in diesem Abschnitt.</p>
|
||||
) : (
|
||||
<SortierBereich
|
||||
@@ -137,7 +170,7 @@ export function AbschnittKarte({
|
||||
</SortierBereich>
|
||||
)}
|
||||
|
||||
{lesend ? null : (
|
||||
{baustein || lesend ? null : (
|
||||
<TextKnopf
|
||||
beschriftung={voll ? `Höchstens ${GRENZEN.schritteJeAbschnitt} Schritte` : "Schritt hinzufügen"}
|
||||
aus={voll}
|
||||
|
||||
@@ -13,7 +13,9 @@ import { Vorschau } from "@/components/vorlagen/vorschau";
|
||||
import {
|
||||
alsEditorAufbau,
|
||||
alsStruktur,
|
||||
bausteineAnreichern,
|
||||
neuerAbschnitt,
|
||||
neuerBausteinAbschnitt,
|
||||
neuerSchritt,
|
||||
neueVariable,
|
||||
umsortieren,
|
||||
@@ -67,20 +69,33 @@ type SpeicherZustand = {
|
||||
readonly gespeichertesJson: string;
|
||||
};
|
||||
|
||||
export type EinsetzbarerBaustein = {
|
||||
readonly bausteinVersionId: string;
|
||||
readonly name: string;
|
||||
readonly titel: string;
|
||||
readonly version: number;
|
||||
readonly anzahlSchritte: number;
|
||||
};
|
||||
|
||||
export function Editor({
|
||||
version,
|
||||
lesend,
|
||||
darfBearbeiten,
|
||||
beispielDatum,
|
||||
bausteine = [],
|
||||
}: {
|
||||
version: EditorVersion;
|
||||
lesend: boolean;
|
||||
darfBearbeiten: boolean;
|
||||
/** Heutiges Datum, auf dem Server formatiert — siehe `beispielwerte`. */
|
||||
beispielDatum: string;
|
||||
/** Veröffentlichte Bausteine, die sich einsetzen lassen (#030). */
|
||||
bausteine?: readonly EinsetzbarerBaustein[];
|
||||
}) {
|
||||
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]);
|
||||
|
||||
/**
|
||||
@@ -92,8 +107,8 @@ export function Editor({
|
||||
* Speichern in die Datenbank.
|
||||
*/
|
||||
const anfangsJson = useMemo(
|
||||
() => JSON.stringify(alsStruktur(alsEditorAufbau(version.struktur))),
|
||||
[version.struktur],
|
||||
() => JSON.stringify(alsStruktur(bausteineAnreichern(alsEditorAufbau(version.struktur), bausteine))),
|
||||
[version.struktur, bausteine],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -359,6 +374,7 @@ export function Editor({
|
||||
</SortierBereich>
|
||||
)}
|
||||
{lesend ? null : (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<TextKnopf
|
||||
beschriftung={
|
||||
abschnitteVoll ? `Höchstens ${GRENZEN.abschnitte} Abschnitte` : "Abschnitt hinzufügen"
|
||||
@@ -370,6 +386,40 @@ export function Editor({
|
||||
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>
|
||||
|
||||
|
||||
@@ -41,6 +41,18 @@ export type EditorAbschnitt = {
|
||||
readonly kennung: string;
|
||||
readonly titel: string;
|
||||
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 = {
|
||||
@@ -207,6 +219,63 @@ export function neuerAbschnitt(): EditorAbschnitt {
|
||||
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 {
|
||||
return {
|
||||
kennung: neueKennung("variable"),
|
||||
@@ -233,6 +302,11 @@ export function alsEditorAufbau(struktur: Struktur): EditorAufbau {
|
||||
kennung: neueKennung("abschnitt"),
|
||||
// Die Nummer gehoert zur Position, nicht in den Titel — siehe titelOhneNummer.
|
||||
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) => ({
|
||||
kennung: neueKennung("schritt"),
|
||||
titel: schritt.titel,
|
||||
@@ -266,7 +340,16 @@ export function alsStruktur(aufbau: EditorAufbau): Struktur {
|
||||
})),
|
||||
abschnitte: aufbau.abschnitte.map((abschnitt) => ({
|
||||
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,
|
||||
hilfetext: schritt.hilfetext.trim() === "" ? undefined : schritt.hilfetext,
|
||||
typ: schritt.typ,
|
||||
|
||||
@@ -96,6 +96,17 @@ export type SchrittEingabe = z.input<typeof SchrittSchema>;
|
||||
|
||||
export const AbschnittSchema = z.object({
|
||||
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
|
||||
.array(SchrittSchema)
|
||||
.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.
|
||||
*
|
||||
@@ -443,7 +526,14 @@ export async function entwurfSpeichern(
|
||||
|
||||
for (const [index, abschnitt] of struktur.abschnitte.entries()) {
|
||||
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) {
|
||||
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
|
||||
// Sekunde veroeffentlicht. Ohne diese Bedingung entstuenden zwei
|
||||
// Protokolleintraege fuer denselben Vorgang.
|
||||
@@ -537,6 +632,10 @@ export async function versionVeroeffentlichen(
|
||||
status: VersionStatus.VEROEFFENTLICHT,
|
||||
version: zeile.version,
|
||||
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 () => {
|
||||
expect(await namen(`SELECT tgname AS name FROM pg_trigger WHERE NOT tgisinternal`)).toEqual([
|
||||
"BausteinSchritt_unveraenderlich",
|
||||
"BausteinVersion_status_einbahn",
|
||||
"VorlagenAbschnitt_baustein_veroeffentlicht",
|
||||
"VorlagenAbschnitt_unveraenderlich",
|
||||
"VorlagenSchritt_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%'`,
|
||||
),
|
||||
).toEqual([
|
||||
"BausteinVersion_ein_entwurf",
|
||||
"Kunde_eigene_organisation_einmalig",
|
||||
"Vorlage_seedSchluessel_einmalig",
|
||||
"VorlagenVersion_ein_entwurf",
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
verwendetePlatzhalter,
|
||||
type EditorAufbau,
|
||||
type EditorVariable,
|
||||
bausteineAnreichern,
|
||||
neuerBausteinAbschnitt,
|
||||
} from "@/lib/editor-aufbau";
|
||||
import { EINGEBAUTE_PLATZHALTER } from "@/lib/platzhalter";
|
||||
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