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
@@ -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 { abteilungsListe } from "@/server/queries/abteilungen";
import { einsetzbareBausteine } from "@/server/queries/bausteine";
import { versionMitAufbau } from "@/server/queries/vorlagen";
@@ -34,6 +35,17 @@ export default async function VersionSeite({ params }: { params: Promise<{ versi
* und das waere eine Falschaussage ueber die Herkunft.
*/
const bausteine = await einsetzbareBausteine();
/*
* Die gepflegten Abteilungen fuer die Einschraenkung je Abschnitt (#031).
* Namen und nicht Kennungen: der Abschnitt gehoert zur Version und muss ein
* Umbenennen ueberleben. Dubletten ueber Kunden hinweg fallen weg — die
* Einschraenkung gilt ueber den Namen.
*/
const abteilungen = [
...new Set(
(await abteilungsListe(benutzer)).filter((zeile) => !zeile.archiviert).map((zeile) => zeile.name),
),
].sort((links, rechts) => links.localeCompare(rechts, "de"));
return (
<Editor
@@ -50,6 +62,7 @@ export default async function VersionSeite({ params }: { params: Promise<{ versi
lesend={veroeffentlicht || !darfBearbeiten}
darfBearbeiten={darfBearbeiten}
bausteine={bausteine}
abteilungen={abteilungen}
// 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", {
+11 -3
View File
@@ -59,6 +59,9 @@ export function BausteinEditor({ entwurf }: { entwurf: BausteinEntwurf }) {
abschnitte: [
{
titel: entwurf.titel,
// Ein Baustein kennt keine Abteilungseinschränkung: die gehört zur
// Vorlage, in der er landet (#031).
nurFuerAbteilungen: [],
schritte: entwurf.schritte.map((schritt) => ({
titel: schritt.titel,
...(schritt.hilfetext === null ? {} : { hilfetext: schritt.hilfetext }),
@@ -91,11 +94,16 @@ export function BausteinEditor({ entwurf }: { entwurf: BausteinEntwurf }) {
);
const jetzt = JSON.stringify(
alsStruktur({ variablen: [], abschnitte: [{ kennung: "x", titel, schritte }] }).abschnitte[0],
alsStruktur({
variablen: [],
abschnitte: [{ kennung: "x", titel, schritte, nurFuerAbteilungen: [] }],
}).abschnitte[0],
);
const vorher = JSON.stringify(
alsStruktur({ variablen: [], abschnitte: [anfang ?? { kennung: "x", titel: "", schritte: [] }] })
.abschnitte[0],
alsStruktur({
variablen: [],
abschnitte: [anfang ?? { kennung: "x", titel: "", schritte: [], nurFuerAbteilungen: [] }],
}).abschnitte[0],
);
const geaendert = jetzt !== vorher;
const voll = schritte.length >= GRENZEN.schritteJeAbschnitt;
+52 -1
View File
@@ -51,6 +51,8 @@ export function LaufStarten({
const [mitarbeiterId, setzeMitarbeiter] = useState(vorbelegung.mitarbeiterId ?? "");
const [freitext, setzeFreitext] = useState("");
const [kundeId, setzeKunde] = useState("");
/** Nur fuer den Hinweis, welche Abschnitte die Abteilung hinzunimmt (#031). */
const [abteilungId, setzeAbteilung] = useState("");
const [variablen, setzeVariablen] = useState<Record<string, string>>({});
const vorlage = useMemo(
@@ -58,6 +60,30 @@ export function LaufStarten({
[vorlagen, versionId],
);
/**
* Welche Abschnitte die gewaehlte Abteilung hinzunimmt — und welche fuer
* andere gelten und deshalb wegbleiben (#031).
*
* Verglichen wird wie im Dienst: ohne Ruecksicht auf Gross- und
* Kleinschreibung. Wuerde hier anders verglichen als dort, zeigte der Dialog
* etwas anderes an, als der Lauf danach enthaelt.
*/
const { zusatzAbschnitte, nurFuerAndere } = useMemo(() => {
const eingeschraenkt = vorlage?.abteilungsAbschnitte ?? [];
const name = auswahl.abteilungen
.find((abteilung) => abteilung.id === abteilungId)
?.name.trim()
.toLocaleLowerCase("de");
const passt = (abteilungen: readonly string[]): boolean =>
name !== undefined && abteilungen.some((eintrag) => eintrag.trim().toLocaleLowerCase("de") === name);
return {
zusatzAbschnitte: eingeschraenkt
.filter((abschnitt) => passt(abschnitt.abteilungen))
.map((abschnitt) => abschnitt.titel),
nurFuerAndere: eingeschraenkt.filter((abschnitt) => !passt(abschnitt.abteilungen)),
};
}, [vorlage, abteilungId, auswahl.abteilungen]);
// Nach dem Anlegen direkt in den Lauf – wer ihn startet, will ihn abarbeiten.
useEffect(() => {
if (ergebnis.laufId) router.push(`/laeufe/${ergebnis.laufId}`);
@@ -284,7 +310,12 @@ export function LaufStarten({
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Abteilung</span>
<select name="abteilungId" defaultValue="" className={FELD}>
<select
name="abteilungId"
value={abteilungId}
onChange={(ereignis) => setzeAbteilung(ereignis.target.value)}
className={FELD}
>
<option value="">keine</option>
{auswahl.abteilungen.map((abteilung) => (
<option key={abteilung.id} value={abteilung.id}>
@@ -292,6 +323,26 @@ export function LaufStarten({
</option>
))}
</select>
{/*
Vorher sagen, was die Abteilung hinzunimmt (#031). Ohne das wäre
die Zahl der Schritte eine Überraschung, und wer sich bei der
Abteilung vertippt, merkt es erst in der Abarbeitung.
*/}
{zusatzAbschnitte.length > 0 ? (
<span className="text-xs text-nebentext">
Nimmt{" "}
{zusatzAbschnitte.length === 1
? "einen Abschnitt"
: `${zusatzAbschnitte.length} Abschnitte`}{" "}
hinzu: {zusatzAbschnitte.join(", ")}
</span>
) : nurFuerAndere.length > 0 ? (
<span className="text-xs text-nebentext">
{nurFuerAndere.length === 1
? "Ein Abschnitt gilt nur für andere Abteilungen und bleibt weg."
: `${nurFuerAndere.length} Abschnitte gelten nur für andere Abteilungen und bleiben weg.`}
</span>
) : null}
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Zuweisen an</span>
@@ -0,0 +1,137 @@
"use client";
import { useState } from "react";
import { Plus, X } from "lucide-react";
import { KAESTCHEN } from "@/lib/formular";
/**
* Fuer welche Abteilungen ein Abschnitt gilt (#031).
*
* Kein `<select multiple>`: das ist am Handy kaum bedienbar und zeigt nicht, was
* gewaehlt ist, ohne zu scrollen. Stattdessen ein Kaestchen je gepflegter
* Abteilung und darunter ein Freitextfeld — der Abschnitt haelt **Namen**, und
* eine Abteilung, die es noch nicht gibt, soll sich schon nennen lassen.
*
* Ein Name, der gewaehlt ist, aber in keiner gepflegten Abteilung vorkommt,
* erscheint als eigene Zeile mit Entfernen-Knopf. Sonst waere er unsichtbar und
* beim naechsten Speichern still weg.
*/
export function AbteilungenWaehlen({
gewaehlt,
bekannte,
lesend,
setzen,
}: {
gewaehlt: readonly string[];
bekannte: readonly string[];
lesend: boolean;
setzen: (namen: readonly string[]) => void;
}) {
const [eingabe, setzeEingabe] = useState("");
const kleinGewaehlt = new Set(gewaehlt.map((name) => name.trim().toLocaleLowerCase("de")));
const fremde = gewaehlt.filter(
(name) => !bekannte.some((bekannt) => bekannt.toLocaleLowerCase("de") === name.toLocaleLowerCase("de")),
);
function umschalten(name: string, an: boolean): void {
if (an) {
if (kleinGewaehlt.has(name.trim().toLocaleLowerCase("de"))) return;
setzen([...gewaehlt, name]);
} else {
setzen(
gewaehlt.filter(
(eintrag) => eintrag.trim().toLocaleLowerCase("de") !== name.trim().toLocaleLowerCase("de"),
),
);
}
}
function hinzufuegen(): void {
const name = eingabe.trim();
if (!name) return;
setzeEingabe("");
umschalten(name, true);
}
return (
<fieldset className="flex flex-col gap-2">
<legend className="text-sm text-nebentext">Nur für diese Abteilungen</legend>
<p className="max-w-prose text-sm text-nebentext">
{gewaehlt.length === 0
? "Keine Einschränkung — der Abschnitt gilt für jeden Lauf."
: "Der Abschnitt erscheint nur in Läufen mit einer dieser Abteilungen. Ein Lauf ohne Abteilung bekommt ihn nicht."}
</p>
{bekannte.length > 0 ? (
<div className="flex flex-wrap gap-x-4 gap-y-1">
{bekannte.map((name) => (
<label key={name} className="flex min-h-11 items-center gap-2 text-sm md:min-h-9">
<input
type="checkbox"
className={KAESTCHEN}
disabled={lesend}
checked={kleinGewaehlt.has(name.toLocaleLowerCase("de"))}
onChange={(ereignis) => umschalten(name, ereignis.target.checked)}
/>
{name}
</label>
))}
</div>
) : null}
{fremde.length > 0 ? (
<ul className="flex flex-wrap gap-2">
{fremde.map((name) => (
<li
key={name}
className="inline-flex items-center gap-1 rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs"
>
{name}
{lesend ? null : (
<button
type="button"
onClick={() => umschalten(name, false)}
aria-label={`${name} entfernen`}
className="text-nebentext hover:text-fehler"
>
<X className="size-3.5" strokeWidth={2} aria-hidden="true" />
</button>
)}
</li>
))}
</ul>
) : null}
{lesend ? null : (
<div className="flex flex-wrap items-center gap-2">
<input
type="text"
value={eingabe}
onChange={(ereignis) => setzeEingabe(ereignis.target.value)}
onKeyDown={(ereignis) => {
// Enter fügt hinzu, statt das Formular abzuschicken – der Editor
// speichert über einen eigenen Knopf.
if (ereignis.key === "Enter") {
ereignis.preventDefault();
hinzufuegen();
}
}}
placeholder="Abteilung, die es noch nicht gibt"
aria-label="Weitere Abteilung"
className="min-h-11 min-w-0 flex-1 rounded-karte border border-linie bg-grund px-3 text-sm outline-none focus-visible:border-akzent md:min-h-9"
/>
<button
type="button"
onClick={hinzufuegen}
disabled={eingabe.trim() === ""}
className="flex min-h-11 items-center gap-1.5 rounded-pille border border-linie px-3 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text disabled:opacity-40 md:min-h-9"
>
<Plus className="size-4" strokeWidth={1.75} aria-hidden="true" />
hinzufügen
</button>
</div>
)}
</fieldset>
);
}
@@ -2,6 +2,7 @@
import { useState } from "react";
import { Blocks } from "lucide-react";
import { AbteilungenWaehlen } from "@/components/vorlagen/abteilungen-waehlen";
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";
@@ -30,6 +31,7 @@ export function AbschnittKarte({
index,
anzahl,
lesend,
abteilungen,
zuletztAngelegt,
anfasser,
aendern,
@@ -44,6 +46,8 @@ export function AbschnittKarte({
index: number;
anzahl: number;
lesend: boolean;
/** Die gepflegten Abteilungsnamen – für die Einschränkung (#031). */
abteilungen: readonly string[];
/** Kennung der zuletzt angelegten Zeile — die wird aufgeklappt gezeigt. */
zuletztAngelegt: string | null;
anfasser: React.ReactNode;
@@ -83,6 +87,11 @@ export function AbschnittKarte({
Baustein
</span>
) : null}
{abschnitt.nurFuerAbteilungen.length > 0 ? (
<span className="shrink-0 rounded-pille bg-flaeche-hoch px-2.5 py-0.5 text-xs font-normal text-nebentext">
nur {abschnitt.nurFuerAbteilungen.join(", ")}
</span>
) : null}
</>
}
knoepfe={
@@ -139,6 +148,17 @@ export function AbschnittKarte({
/>
) : null}
{/*
Die Einschraenkung gehoert zur Vorlage und nicht zum Baustein — deshalb
steht sie auch bei einem Baustein-Abschnitt (#031).
*/}
<AbteilungenWaehlen
gewaehlt={abschnitt.nurFuerAbteilungen}
bekannte={abteilungen}
lesend={lesend}
setzen={(nurFuerAbteilungen) => aendern({ nurFuerAbteilungen })}
/>
{baustein ? null : anzahlSchritte === 0 ? (
<p className="text-sm text-nebentext">Noch kein Schritt in diesem Abschnitt.</p>
) : (
+4
View File
@@ -83,6 +83,7 @@ export function Editor({
darfBearbeiten,
beispielDatum,
bausteine = [],
abteilungen = [],
}: {
version: EditorVersion;
lesend: boolean;
@@ -91,6 +92,8 @@ export function Editor({
beispielDatum: string;
/** Veröffentlichte Bausteine, die sich einsetzen lassen (#030). */
bausteine?: readonly EinsetzbarerBaustein[];
/** Gepflegte Abteilungsnamen – für die Einschränkung je Abschnitt (#031). */
abteilungen?: readonly string[];
}) {
const router = useRouter();
const [aufbau, setzeAufbau] = useState<EditorAufbau>(() =>
@@ -336,6 +339,7 @@ export function Editor({
abschnitt={abschnitt}
index={index}
anzahl={aufbau.abschnitte.length}
abteilungen={abteilungen}
lesend={lesend}
zuletztAngelegt={zuletztAngelegt}
anfasser={anfasser}
+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(
+6 -1
View File
@@ -1,5 +1,6 @@
import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { nurGeltende } from "@/lib/geltende-abschnitte";
import { platzhalterErsetzen } from "@/lib/platzhalter-werte";
import { kundenEbeneSichtbar, sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
import { kommentareDesLaufs, type KommentarZeile } from "@/server/queries/laeufe";
@@ -163,6 +164,8 @@ export async function laufZumDrucken(
zugewiesenAn: { select: { name: true } },
abgeschlossenVon: { select: { name: true } },
archiviertVon: { select: { name: true } },
// Beim Start festgeschriebene Abschnittsauswahl (#031).
geltendeAbschnitte: true,
vorlagenVersion: {
select: {
version: true,
@@ -219,7 +222,9 @@ export async function laufZumDrucken(
let anzahlSchritte = 0;
let anzahlVomTisch = 0;
const abschnitte = lauf.vorlagenVersion.abschnitte.map((abschnitt, index) => {
// Dieselbe Auswahl wie in der Abarbeitung – nicht mehr und nicht weniger (#031).
const geltende = nurGeltende(lauf.vorlagenVersion.abschnitte, lauf.geltendeAbschnitte);
const abschnitte = geltende.map((abschnitt, index) => {
const schritte = abschnitt.schritte.map((schritt) => {
nummer += 1;
anzahlSchritte += 1;
+25 -1
View File
@@ -1,6 +1,7 @@
import type { Prisma } from "@/generated/prisma/client";
import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { nurGeltende } from "@/lib/geltende-abschnitte";
import { platzhalterErsetzen } from "@/lib/platzhalter-werte";
import { platzhalterAusserhalbAnfuehrungszeichen, skriptBauen, wertMaskieren, type Shell } from "@/lib/shell";
import { STANDARD_SHELL } from "@/lib/editor-aufbau";
@@ -236,6 +237,8 @@ export async function laufZumAbarbeiten(
const lauf = await prisma.lauf.findUniqueOrThrow({
where: { id: laufId },
select: {
// Beim Start festgeschriebene Abschnittsauswahl (#031).
geltendeAbschnitte: true,
vorlagenVersion: {
select: {
abschnitte: {
@@ -294,7 +297,8 @@ export async function laufZumAbarbeiten(
let anzahlPflicht = 0;
let anzahlPflichtOffen = 0;
const abschnitte = lauf.vorlagenVersion.abschnitte.map((abschnitt, index) => {
const geltende = nurGeltende(lauf.vorlagenVersion.abschnitte, lauf.geltendeAbschnitte);
const abschnitte = geltende.map((abschnitt, index) => {
const befehle: { titel: string; shell: Shell; befehl: string }[] = [];
const schritte = abschnitt.schritte.map((schritt) => {
@@ -599,6 +603,17 @@ export type StartbareVorlage = {
readonly optionen: readonly string[];
readonly pflicht: boolean;
}[];
/**
* Abschnitte, die nur fuer bestimmte Abteilungen gelten (#031).
*
* Der Start-Dialog sagt damit **vorher**, was die gewaehlte Abteilung
* hinzunimmt. Ohne das waere die Zahl der Schritte eine Ueberraschung, und wer
* sich bei der Abteilung vertippt, merkt es erst in der Abarbeitung.
*/
readonly abteilungsAbschnitte: readonly {
readonly titel: string;
readonly abteilungen: readonly string[];
}[];
};
/**
@@ -641,6 +656,11 @@ export async function startbareVorlagen(benutzer: HandelnderBenutzer): Promise<r
pflicht: true,
},
},
abschnitte: {
where: { NOT: { nurFuerAbteilungen: { isEmpty: true } } },
orderBy: { position: "asc" },
select: { titel: true, nurFuerAbteilungen: true },
},
},
},
},
@@ -659,6 +679,10 @@ export async function startbareVorlagen(benutzer: HandelnderBenutzer): Promise<r
version: version.version,
kategorie: vorlage.kategorie,
variablen: version.variablen,
abteilungsAbschnitte: version.abschnitte.map((abschnitt) => ({
titel: abschnitt.titel,
abteilungen: abschnitt.nurFuerAbteilungen,
})),
});
}
return startbare;
+2
View File
@@ -266,6 +266,8 @@ export async function versionMitAufbau(
})),
abschnitte: version.abschnitte.map((abschnitt) => ({
titel: abschnitt.titel,
nurFuerAbteilungen: abschnitt.nurFuerAbteilungen,
...(abschnitt.bausteinVersionId === null ? {} : { bausteinVersionId: abschnitt.bausteinVersionId }),
schritte: abschnitt.schritte.map((schritt) => ({
titel: schritt.titel,
...(schritt.hilfetext === null ? {} : { hilfetext: schritt.hilfetext }),
+70 -5
View File
@@ -1,6 +1,7 @@
import { Prisma, type Lauf } from "@/generated/prisma/client";
import { LaufStatus, SchrittZustand, VariablenTyp } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { geltendeAbschnitteErmitteln, nurGeltende } from "@/lib/geltende-abschnitte";
import { darf } from "@/server/auth/rollen";
import {
MandantFehler,
@@ -190,6 +191,13 @@ type VersionFuerLauf = {
readonly pflicht: boolean;
}[];
readonly schrittIds: readonly string[];
/** Fuer die Auswahl nach Abteilung beim Start (#031). */
readonly abschnitte: readonly {
readonly id: string;
readonly titel: string;
readonly nurFuerAbteilungen: readonly string[];
readonly schrittIds: readonly string[];
}[];
};
/**
@@ -219,7 +227,12 @@ async function versionFuerLaufHolen(versionId: string, kundeId: string): Promise
},
abschnitte: {
orderBy: { position: "asc" },
select: { schritte: { orderBy: { position: "asc" }, select: { id: true } } },
select: {
id: true,
titel: true,
nurFuerAbteilungen: true,
schritte: { orderBy: { position: "asc" }, select: { id: true } },
},
},
},
});
@@ -238,6 +251,12 @@ async function versionFuerLaufHolen(versionId: string, kundeId: string): Promise
vorlagenName: version.vorlage.name,
variablen: version.variablen,
schrittIds: version.abschnitte.flatMap((abschnitt) => abschnitt.schritte.map((s) => s.id)),
abschnitte: version.abschnitte.map((abschnitt) => ({
id: abschnitt.id,
titel: abschnitt.titel,
nurFuerAbteilungen: abschnitt.nurFuerAbteilungen,
schrittIds: abschnitt.schritte.map((schritt) => schritt.id),
})),
};
}
@@ -246,6 +265,8 @@ type Bezuege = {
readonly mitarbeiterName: string | null;
readonly geraetId: string | null;
readonly abteilungId: string | null;
/** Name der gewaehlten Abteilung – fuer die Abschnittsauswahl (#031). */
readonly abteilungsName: string | null;
/** Nur fuer den Titelvorschlag. */
readonly geraetName: string | null;
readonly personName: string | null;
@@ -310,6 +331,7 @@ async function bezuegePruefen(
}
const abteilungId = text(eingabe.abteilungId);
let abteilungsName: string | null = null;
if (abteilungId) {
const abteilung = await prisma.abteilung.findUnique({
where: { id: abteilungId },
@@ -322,9 +344,18 @@ async function bezuegePruefen(
if (abteilung.archiviert) {
throw new LaufFehler(`Die Abteilung „${abteilung.name}“ ist archiviert.`);
}
abteilungsName = abteilung.name;
}
return { mitarbeiterId, mitarbeiterName, geraetId, abteilungId, geraetName, personName };
return {
mitarbeiterId,
mitarbeiterName,
geraetId,
abteilungId,
abteilungsName,
geraetName,
personName,
};
}
/**
@@ -441,6 +472,28 @@ export async function laufAnlegen(eingabe: NeuerLauf, benutzer: HandelnderBenutz
person: bezuege.personName,
});
/*
* Die Abschnittsauswahl **jetzt** festlegen und am Lauf festhalten (#031).
* Danach aendert sie sich nie wieder: ein spaeterer Abteilungswechsel darf
* keinen Schritt entfernen, an dem schon eine Notiz haengt, und ein
* archivierter Lauf muss genau das zeigen, was damals abgearbeitet wurde.
*/
const geltende = geltendeAbschnitteErmitteln(version.abschnitte, bezuege.abteilungsName);
if (version.abschnitte.length > 0 && geltende.length === 0) {
throw new LaufFehler(
bezuege.abteilungsName === null
? `Von „${version.vorlagenName}“ gilt ohne Abteilung kein Abschnitt — jeder ist auf ` +
"bestimmte Abteilungen eingeschränkt. Wähle eine Abteilung."
: `Von „${version.vorlagenName}“ gilt für die Abteilung „${bezuege.abteilungsName}“ kein ` +
"Abschnitt. Es gäbe nichts abzuarbeiten.",
);
}
const geltendeSchrittIds = new Set(
version.abschnitte
.filter((abschnitt) => geltende.includes(abschnitt.id))
.flatMap((abschnitt) => abschnitt.schrittIds),
);
return prisma.$transaction(async (tx) => {
const lauf = await tx.lauf.create({
data: {
@@ -457,12 +510,17 @@ export async function laufAnlegen(eingabe: NeuerLauf, benutzer: HandelnderBenutz
zugewiesenAnId,
faelligAm: eingabe.faelligAm ?? null,
erstelltVonId: benutzer.id,
geltendeAbschnitte: [...geltende],
},
});
if (version.schrittIds.length > 0) {
// Nur fuer die geltenden Abschnitte. Ein Schrittergebnis zu einem
// ausgefilterten Schritt waere eine Zeile, die niemand je sieht — und sie
// zaehlte in jeder Statistik mit.
const anzulegen = version.schrittIds.filter((id) => geltendeSchrittIds.has(id));
if (anzulegen.length > 0) {
await tx.schrittergebnis.createMany({
data: version.schrittIds.map((vorlagenSchrittId) => ({
data: anzulegen.map((vorlagenSchrittId) => ({
laufId: lauf.id,
vorlagenSchrittId,
zustand: SchrittZustand.OFFEN,
@@ -605,11 +663,13 @@ export async function offenePflichtschritte(
const lauf = await tx.lauf.findUniqueOrThrow({
where: { id: laufId },
select: {
geltendeAbschnitte: true,
vorlagenVersion: {
select: {
abschnitte: {
orderBy: { position: "asc" },
select: {
id: true,
titel: true,
schritte: {
where: { pflicht: true },
@@ -626,7 +686,12 @@ export async function offenePflichtschritte(
const zustaende = new Map(lauf.ergebnisse.map((e) => [e.vorlagenSchrittId, e.zustand]));
const offen: OffenerPflichtschritt[] = [];
for (const abschnitt of lauf.vorlagenVersion.abschnitte) {
/*
* Nur die geltenden Abschnitte (#031). Ein ausgefilterter Pflichtschritt darf
* den Abschluss nicht blockieren — sonst waere er unerreichbar: abhaken laesst
* sich nur, was in der Abarbeitung steht.
*/
for (const abschnitt of nurGeltende(lauf.vorlagenVersion.abschnitte, lauf.geltendeAbschnitte)) {
for (const schritt of abschnitt.schritte) {
// Ohne Ergebniszeile gilt OFFEN – ein Schritt ohne Eintrag ist nicht erledigt.
const zustand = zustaende.get(schritt.id) ?? SchrittZustand.OFFEN;
@@ -70,6 +70,9 @@ export async function vorlageExportieren(versionId: string): Promise<Austausch>
})),
abschnitte: version.abschnitte.map((abschnitt) => ({
titel: abschnitt.titel,
// Wandert mit: ein Abteilungsname bedeutet in einer anderen Installation
// dasselbe. Der Baustein-Verweis wandert bewusst **nicht** mit (#030).
nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen],
schritte: abschnitt.schritte.map(schrittAbbilden),
})),
},
+1
View File
@@ -533,6 +533,7 @@ export async function entwurfSpeichern(
// Ein Trigger laesst nur veroeffentlichte Baustein-Versionen zu; der
// Dienst prueft dasselbe mit besserer Meldung (#030).
bausteinVersionId: abschnitt.bausteinVersionId ?? null,
nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen],
},
});
if (abschnitt.schritte.length > 0) {