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", {