feat(vorlagen): Abschnitte nur fuer bestimmte Abteilungen (#58)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

This commit was merged in pull request #58.
This commit is contained in:
2026-10-04 18:04:51 +00:00
parent 80870133b8
commit 855164066c
24 changed files with 815 additions and 37 deletions
+12 -1
View File
@@ -41,6 +41,8 @@ export type EditorAbschnitt = {
readonly kennung: string;
readonly titel: string;
readonly schritte: readonly EditorSchritt[];
/** Nur fuer diese Abteilungen; leer heisst fuer alle (#031). */
readonly nurFuerAbteilungen: readonly string[];
/**
* Gesetzt, wenn dieser Abschnitt ein eingesetzter **Baustein** ist (#030).
*
@@ -216,7 +218,7 @@ export function neuerSchritt(): EditorSchritt {
}
export function neuerAbschnitt(): EditorAbschnitt {
return { kennung: neueKennung("abschnitt"), titel: "", schritte: [] };
return { kennung: neueKennung("abschnitt"), titel: "", schritte: [], nurFuerAbteilungen: [] };
}
/**
@@ -236,6 +238,9 @@ export function neuerBausteinAbschnitt(baustein: {
kennung: neueKennung("abschnitt"),
titel: baustein.titel,
baustein: { versionId: baustein.bausteinVersionId, name: baustein.name, version: baustein.version },
// Ein Baustein-Abschnitt laesst sich einschraenken wie jeder andere: die
// Einschraenkung gehoert zur Vorlage, nicht zum Baustein.
nurFuerAbteilungen: [],
// Platzhalter, damit die Anzahl stimmt. Gespeichert werden sie nicht.
schritte: Array.from({ length: baustein.anzahlSchritte }, () => neuerSchritt()),
};
@@ -302,6 +307,7 @@ export function alsEditorAufbau(struktur: Struktur): EditorAufbau {
kennung: neueKennung("abschnitt"),
// Die Nummer gehoert zur Position, nicht in den Titel — siehe titelOhneNummer.
titel: titelOhneNummer(abschnitt.titel),
nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen],
// 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
@@ -341,6 +347,11 @@ export function alsStruktur(aufbau: EditorAufbau): Struktur {
abschnitte: aufbau.abschnitte.map((abschnitt) => ({
titel: abschnitt.titel,
...(abschnitt.baustein === undefined ? {} : { bausteinVersionId: abschnitt.baustein.versionId }),
// Leere Namen und Dubletten fallen weg: beides entsteht beim Tippen im
// Freitextfeld und bedeutet dasselbe wie "nicht genannt".
nurFuerAbteilungen: [
...new Set(abschnitt.nurFuerAbteilungen.map((name) => name.trim()).filter(Boolean)),
],
/*
* Ein Baustein-Abschnitt gibt **keine** Schritte mit: sie kommen beim
* Veroeffentlichen aus dem Baustein. Die Schritte im Zustand sind nur
+51
View File
@@ -0,0 +1,51 @@
/**
* Welche Abschnitte fuer einen Lauf gelten (#031).
*
* Framework-frei und ohne Prisma, weil **drei** Schichten sie brauchen: der
* Dienst beim Start und beim Abschluss, und die Lesemodelle der Abarbeitung und
* der Druckansicht. Lebte sie in einem davon, importierte ein Dienst aus einem
* Lesemodell — eine Abhaengigkeit in die falsche Richtung (CLAUDE.md).
*/
/**
* Die Auswahl zum Zeitpunkt des Starts.
*
* Ein Abschnitt ohne Einschraenkung gilt immer. Einer mit Einschraenkung gilt,
* wenn die Abteilung des Laufs darin vorkommt — ein Lauf **ohne** Abteilung
* bekommt deshalb nur die uneingeschraenkten, und das ist kein Mangel: ein
* Firewall-Rollout hat keine Abteilung.
*
* Verglichen wird ohne Ruecksicht auf Gross- und Kleinschreibung und mit
* getrimmten Raendern. Die Namen kommen aus zwei Quellen — der gepflegten
* `Abteilung` und dem Freitextfeld im Editor —, und „buchhaltung“ ist dieselbe
* Abteilung wie „Buchhaltung“.
*/
export function geltendeAbschnitteErmitteln(
abschnitte: readonly { readonly id: string; readonly nurFuerAbteilungen: readonly string[] }[],
abteilungsName: string | null,
): readonly string[] {
const gesucht = abteilungsName?.trim().toLocaleLowerCase("de") ?? null;
return abschnitte
.filter((abschnitt) => {
if (abschnitt.nurFuerAbteilungen.length === 0) return true;
if (gesucht === null) return false;
return abschnitt.nurFuerAbteilungen.some((name) => name.trim().toLocaleLowerCase("de") === gesucht);
})
.map((abschnitt) => abschnitt.id);
}
/**
* Die Abschnitte einer Version auf die eines Laufs eindampfen.
*
* `geltendeAbschnitte` ist beim Start festgeschrieben. **Leer heisst alle** — so
* verhalten sich Laeufe von vor dieser Fassung unveraendert, ohne dass eine
* Migration Daten nachfuehren muesste.
*/
export function nurGeltende<T extends { readonly id: string }>(
abschnitte: readonly T[],
geltendeAbschnitte: readonly string[],
): readonly T[] {
if (geltendeAbschnitte.length === 0) return abschnitte;
const erlaubt = new Set(geltendeAbschnitte);
return abschnitte.filter((abschnitt) => erlaubt.has(abschnitt.id));
}
+12
View File
@@ -107,6 +107,18 @@ export const AbschnittSchema = z.object({
* die sich nicht veroeffentlichen laesst.
*/
bausteinVersionId: leerAlsUndefiniert(z.string().trim().max(60)),
/**
* Nur fuer diese Abteilungen (#031). Leer heisst: fuer alle.
*
* **Namen und nicht Kennungen** — der Abschnitt gehoert zur Version und muss
* ein Umbenennen oder Archivieren einer Abteilung ueberleben. Deshalb wandert
* das Feld auch durch Export und Import: ein Abteilungsname bedeutet in einer
* anderen Installation dasselbe, eine Kennung nicht.
*/
nurFuerAbteilungen: z
.array(z.string().trim().min(1).max(200))
.max(50, "Höchstens 50 Abteilungen je Abschnitt.")
.default([]),
schritte: z
.array(SchrittSchema)
.max(