feat(bausteine): Abschnitte, die mehrere Vorlagen teilen (#57)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

This commit was merged in pull request #57.
This commit is contained in:
2026-10-04 17:07:12 +00:00
parent b688e2ebc8
commit 80870133b8
24 changed files with 2862 additions and 46 deletions
@@ -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>
</>
);
}
+125
View File
@@ -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>
)}
</>
);
}
+11 -2
View File
@@ -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 (
<>
<h1 className="text-2xl font-bold">Vorlagen</h1>
<div className="flex flex-wrap items-baseline justify-between gap-3">
<h1 className="text-2xl font-bold">Vorlagen</h1>
<Link
href="/vorlagen/bausteine"
className="flex min-h-11 items-center gap-2 rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10"
>
<Blocks className="size-4" strokeWidth={1.75} aria-hidden="true" />
Bausteine
</Link>
</div>
<p className="mt-2 max-w-prose text-nebentext">
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>
);
}
+151
View File
@@ -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>
);
}
+37 -4
View File
@@ -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}
+64 -14
View File
@@ -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,17 +374,52 @@ export function Editor({
</SortierBereich>
)}
{lesend ? null : (
<TextKnopf
beschriftung={
abschnitteVoll ? `Höchstens ${GRENZEN.abschnitte} Abschnitte` : "Abschnitt hinzufügen"
}
aus={abschnitteVoll}
aufKlick={() => {
const abschnitt = neuerAbschnitt();
setzeZuletztAngelegt(abschnitt.kennung);
abschnitteSetzen([...aufbau.abschnitte, abschnitt]);
}}
/>
<div className="flex flex-wrap items-center gap-3">
<TextKnopf
beschriftung={
abschnitteVoll ? `Höchstens ${GRENZEN.abschnitte} Abschnitte` : "Abschnitt hinzufügen"
}
aus={abschnitteVoll}
aufKlick={() => {
const abschnitt = neuerAbschnitt();
setzeZuletztAngelegt(abschnitt.kennung);
abschnitteSetzen([...aufbau.abschnitte, abschnitt]);
}}
/>
{/*
Bausteine nur anbieten, wenn es welche gibt. Ein leeres
Auswahlfeld mit „keine Bausteine“ wäre eine Frage ohne Antwort
(#030).
*/}
{bausteine.length > 0 ? (
<label className="flex min-w-0 items-center gap-2 text-sm">
<span className="shrink-0 text-nebentext">Baustein einsetzen</span>
<select
aria-label="Baustein einsetzen"
value=""
disabled={abschnitteVoll}
onChange={(ereignis) => {
const gewaehlt = bausteine.find(
(eintrag) => eintrag.bausteinVersionId === ereignis.target.value,
);
if (!gewaehlt) return;
const abschnitt = neuerBausteinAbschnitt(gewaehlt);
setzeZuletztAngelegt(abschnitt.kennung);
abschnitteSetzen([...aufbau.abschnitte, abschnitt]);
}}
className="min-h-11 min-w-0 rounded-pille border border-linie bg-flaeche-hoch px-4 text-sm md:min-h-10"
>
<option value="">auswählen …</option>
{bausteine.map((eintrag) => (
<option key={eintrag.bausteinVersionId} value={eintrag.bausteinVersionId}>
{eintrag.name} (Fassung {eintrag.version}, {eintrag.anzahlSchritte}{" "}
{eintrag.anzahlSchritte === 1 ? "Schritt" : "Schritte"})
</option>
))}
</select>
</label>
) : null}
</div>
)}
</section>
+84 -1
View File
@@ -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,
+11
View File
@@ -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(
+216
View File
@@ -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.");
}
}
+226
View File
@@ -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,
},
];
});
}
+385
View File
@@ -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 },
},
});
});
}
+100 -1
View File
@@ -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 } : {}),
},
},
});