feat(bausteine): Abschnitte, die mehrere Vorlagen teilen (#57)
This commit was merged in pull request #57.
This commit is contained in:
@@ -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 (
|
||||
<>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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,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>
|
||||
|
||||
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user