feat(vorlagen): Abschnitte nur fuer bestimmte Abteilungen #58

Merged
florian.wilde merged 1 commits from feat/51-abteilungsabschnitte into main 2026-10-04 18:04:52 +00:00
24 changed files with 815 additions and 37 deletions
Showing only changes of commit b8166e10ea - Show all commits
+7
View File
@@ -26,6 +26,13 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Hinzugefügt ### Hinzugefügt
- **Abschnitte nur für bestimmte Abteilungen.** Beim Onboarding in der Buchhaltung gehören Schritte
dazu, die sonst niemand braucht. Bisher hieß die Wahl: eine Vorlage je Abteilung pflegen, oder
Schritte, die für die meisten „nicht zutreffend“ sind. Jeder Abschnitt kann jetzt sagen, für welche
Abteilungen er gilt; gespeichert werden **Namen**, damit ein Umbenennen die Vorlage nicht
beschädigt. Welche Abschnitte ein Lauf hat, wird **beim Start festgeschrieben** — ein späterer
Abteilungswechsel ändert ihn nicht, und ein ausgefilterter Pflichtschritt blockiert den Abschluss
nicht. Der Start-Dialog sagt vorher, was die gewählte Abteilung hinzunimmt (#031)
- **Wiederverwendbare Bausteine.** Ein Abschnitt, den mehrere Vorlagen teilen und der an einer - **Wiederverwendbare Bausteine.** Ein Abschnitt, den mehrere Vorlagen teilen und der an einer
Stelle gepflegt wird — „Datensicherung prüfen“ steht in vier der sieben mitgelieferten Vorlagen, Stelle gepflegt wird — „Datensicherung prüfen“ steht in vier der sieben mitgelieferten Vorlagen,
wortgleich. Versioniert nach denselben Regeln wie eine Vorlage: höchstens ein Entwurf, eine wortgleich. Versioniert nach denselben Regeln wie eine Vorlage: höchstens ein Entwurf, eine
+5
View File
@@ -40,6 +40,11 @@ Es gibt drei Wege, und der schnellste ist meist nicht dieser:
entsteht und beginnt sofort, mit Gerät und Besitzer, ohne eine einzige Eingabe. Siehe entsteht und beginnt sofort, mit Gerät und Besitzer, ohne eine einzige Eingabe. Siehe
[Kapitel 03](03-qr-tags.md). [Kapitel 03](03-qr-tags.md).
- **Aus der Übersicht**: der Block **Schnellstart** führt mit vorgewählter Vorlage in den Dialog. - **Aus der Übersicht**: der Block **Schnellstart** führt mit vorgewählter Vorlage in den Dialog.
Wählst du im Start-Dialog eine **Abteilung**, steht darunter, welche Abschnitte sie hinzunimmt — oder
dass Abschnitte nur für andere Abteilungen gelten und wegbleiben. Welche Abschnitte ein Lauf hat,
wird beim Start festgeschrieben und ändert sich danach nicht mehr, auch nicht, wenn jemand die
Abteilung des Laufs korrigiert.
- **Über den Knopf Lauf starten** in der Laufliste — der vollständige Weg, hier beschrieben. - **Über den Knopf Lauf starten** in der Laufliste — der vollständige Weg, hier beschrieben.
![Der Start-Dialog mit Vorlage, Gerät, Person und den Angaben zum Lauf](bilder/lauf-starten-desktop.png) ![Der Start-Dialog mit Vorlage, Gerät, Person und den Angaben zum Lauf](bilder/lauf-starten-desktop.png)
+29
View File
@@ -190,6 +190,35 @@ und für `{{generiertes_passwort}}` entsteht **kein** Passwort — dort steht
Nutz sie vor dem Veröffentlichen, besonders bei Befehlen: erst hier sieht man, ob der fertige Befehl Nutz sie vor dem Veröffentlichen, besonders bei Befehlen: erst hier sieht man, ob der fertige Befehl
so aussieht, wie er aussehen soll. so aussieht, wie er aussehen soll.
## Abschnitte nur für bestimmte Abteilungen
Beim Onboarding in der Buchhaltung gehören Schritte dazu, die sonst niemand braucht: DATEV
einrichten, Drucker für Formulare, Zugang zum Bankportal. Bisher hieß die Wahl: eine Vorlage je
Abteilung pflegen, oder Schritte, die für die meisten „nicht zutreffend“ sind.
In jedem Abschnitt steht dafür **Nur für diese Abteilungen**: ein Kästchen je gepflegter Abteilung,
darunter ein Feld für eine, die es noch nicht gibt. Bleibt alles leer, gilt der Abschnitt für jeden
Lauf — das ist der Normalfall.
Drei Dinge, die man dazu wissen muss:
- **Ein Lauf ohne Abteilung bekommt nur die uneingeschränkten Abschnitte.** Ein Firewall-Rollout hat
keine Abteilung, und das ist kein Mangel.
- **Die Auswahl wird beim Start festgeschrieben.** Wer später die Abteilung eines laufenden Laufs
ändert, ändert seine Abschnitte **nicht**. Sonst verschwände ein Schritt, an dem schon eine Notiz
hängt — und ein archivierter Lauf muss genau das zeigen, was damals abgearbeitet wurde.
- **Ein ausgefilterter Pflichtschritt blockiert den Abschluss nicht.** Er steht nicht in der
Abarbeitung, also lässt er sich auch nicht abhaken; würde er zählen, wäre der Abschluss
unerreichbar.
Gespeichert werden **Namen**, nicht Kennungen. Ein Abschnitt gehört zur Version und muss ein
Umbenennen oder Archivieren einer Abteilung überleben, ohne ins Leere zu zeigen — und beim Vergleich
ist Groß- und Kleinschreibung gleichgültig. Deshalb wandert die Einschränkung auch durch Export und
Import: ein Abteilungsname bedeutet in einer anderen Installation dasselbe.
**Lässt für eine Abteilung kein Abschnitt übrig, wird der Lauf abgewiesen** — es gäbe nichts
abzuarbeiten. Das passiert nur, wenn jeder Abschnitt einer Vorlage eingeschränkt ist.
## Bausteine: ein Abschnitt für mehrere Vorlagen ## Bausteine: ein Abschnitt für mehrere Vorlagen
„Datensicherung prüfen“ steht in vier der sieben mitgelieferten Vorlagen, wortgleich. Wer den Ablauf „Datensicherung prüfen“ steht in vier der sieben mitgelieferten Vorlagen, wortgleich. Wer den Ablauf
+38 -18
View File
@@ -1,10 +1,10 @@
# #031 M18 – Abteilungsspezifische Zusatzabschnitte # #031 M18 – Abteilungsspezifische Zusatzabschnitte
**Status:** offen **Status:** erledigt
**Im Tracker:** [#51](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/51) **Im Tracker:** [#51](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/51)
**Meilenstein:** v0.2.0 **Meilenstein:** v0.2.0
**Labels:** phase-2, backend, frontend **Labels:** phase-2, backend, frontend
**Geschätzt:** 8 h **Tatsächlich:** – **Geschätzt:** 8 h **Tatsächlich:** 1 h 35 min
## Ziel ## Ziel
@@ -16,34 +16,54 @@ Ein Abschnitt soll sagen können: **nur für diese Abteilungen.**
## Zu entscheiden ## Zu entscheiden
- [ ] **Die Auswahl wird beim Start festgeschrieben** — Empfehlung. Der Lauf merkt sich, welche - [x] **Die Auswahl wird beim Start festgeschrieben** — Empfehlung. Der Lauf merkt sich, welche
Abschnitte für ihn gelten, statt sie bei jedem Aufruf aus `Lauf.abteilungId` neu zu berechnen. Abschnitte für ihn gelten, statt sie bei jedem Aufruf aus `Lauf.abteilungId` neu zu berechnen.
Grund: ein archivierter Lauf muss genau die Schritte zeigen, die damals abgearbeitet wurden. Grund: ein archivierter Lauf muss genau die Schritte zeigen, die damals abgearbeitet wurden.
Wechselt jemand später die Abteilung des Laufs, dürfen Schritte nicht verschwinden, an denen Wechselt jemand später die Abteilung des Laufs, dürfen Schritte nicht verschwinden, an denen
schon eine Notiz hängt. schon eine Notiz hängt.
- [ ] **Was bei einem Lauf ohne Abteilung gilt:** nur die Abschnitte ohne Einschränkung. Ein - [x] **Was bei einem Lauf ohne Abteilung gilt:** nur die Abschnitte ohne Einschränkung. Ein
Firewall-Rollout hat keine Abteilung, und das ist kein Mangel. Firewall-Rollout hat keine Abteilung, und das ist kein Mangel.
## Aufgaben ## Aufgaben
- [ ] `VorlagenAbschnitt.nurFuerAbteilungen` — Liste von Abteilungsnamen, nicht von Kennungen: der - [x] `VorlagenAbschnitt.nurFuerAbteilungen` — Liste von Abteilungsnamen, nicht von Kennungen: der
Abschnitt gehört zur Version und muss ein Umbenennen oder Archivieren einer Abteilung Abschnitt gehört zur Version und muss ein Umbenennen oder Archivieren einer Abteilung
überleben, ohne ins Leere zu zeigen. Dieselbe Entscheidung wie bei `Mitarbeiter.abteilung` überleben, ohne ins Leere zu zeigen. Dieselbe Entscheidung wie bei `Mitarbeiter.abteilung`
- [ ] Beim Start: die geltenden Abschnitte ermitteln und am Lauf festhalten - [x] Beim Start: die geltenden Abschnitte ermitteln und am Lauf festhalten
- [ ] Die Abarbeitungsansicht zeigt nur geltende Abschnitte; die Nummerierung (`01 ·`) zählt nur sie - [x] Die Abarbeitungsansicht zeigt nur geltende Abschnitte; die Nummerierung (`01 ·`) zählt nur sie
- [ ] **Die Abschlussregel zählt nur geltende Pflichtschritte.** Ein ausgefilterter Pflichtschritt - [x] **Die Abschlussregel zählt nur geltende Pflichtschritte.** Ein ausgefilterter Pflichtschritt
darf einen Lauf nicht blockieren — sonst ist der Abschluss unerreichbar darf einen Lauf nicht blockieren — sonst ist der Abschluss unerreichbar
- [ ] Druckansicht und Archiv zeigen dieselbe Auswahl wie die Abarbeitung - [x] Druckansicht und Archiv zeigen dieselbe Auswahl wie die Abarbeitung
- [ ] Editor: je Abschnitt eine Mehrfachauswahl über die gepflegten Abteilungen, mit Freitext für - [x] Editor: je Abschnitt eine Mehrfachauswahl über die gepflegten Abteilungen, mit Freitext für
eine, die es noch nicht gibt eine, die es noch nicht gibt
- [ ] Der Start-Dialog sagt vorher, wie viele Abschnitte die gewählte Abteilung hinzunimmt - [x] Der Start-Dialog sagt vorher, wie viele Abschnitte die gewählte Abteilung hinzunimmt
- [ ] Anleitung Kapitel 04 und 02 - [x] Anleitung Kapitel 04 und 02
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] Test: ein Lauf für „Buchhaltung“ enthält den Buchhaltungs-Abschnitt, einer für „Vertrieb“ nicht - [x] Test: ein Lauf für „Buchhaltung“ enthält den Buchhaltungs-Abschnitt, einer für „Vertrieb“ nicht
- [ ] Test: ein Lauf ohne Abteilung enthält nur die uneingeschränkten Abschnitte - [x] Test: ein Lauf ohne Abteilung enthält nur die uneingeschränkten Abschnitte
- [ ] Test: die Abteilung eines laufenden Laufs ändern ändert **nicht** seine Abschnitte - [x] Test: die Abteilung eines laufenden Laufs ändern ändert **nicht** seine Abschnitte
- [ ] Test: ein ausgefilterter Pflichtschritt blockiert den Abschluss nicht - [x] Test: ein ausgefilterter Pflichtschritt blockiert den Abschluss nicht
- [ ] Test: die Druckansicht zeigt genau die Abschnitte der Abarbeitung — nicht mehr und nicht weniger - [x] Test: die Druckansicht zeigt genau die Abschnitte der Abarbeitung — nicht mehr und nicht weniger
- [ ] Am Handy kein horizontales Scrollen - [x] Am Handy kein horizontales Scrollen
## Beim Umsetzen dazugekommen
- **Festgeschrieben wird über `Lauf.geltendeAbschnitte`, und leer heißt „alle".** Damit verhalten
sich Läufe von vor dieser Fassung unverändert, ohne dass eine Migration Daten nachführen müsste.
Ein neuer Lauf trägt immer alle geltenden Kennungen — auch wenn nichts eingeschränkt ist, denn
sonst wäre „leer" zweideutig.
- **Ein Lauf, für den kein Abschnitt übrig bleibt, wird abgewiesen.** Das war nötig, damit „leer
heißt alle" eindeutig bleibt — und es ist ohnehin richtig: es gäbe nichts abzuarbeiten.
- **Die Abschlussregel musste nachziehen.** `offenePflichtschritte` zählte den ausgefilterten
Pflichtschritt weiter mit; der Abschluss wäre unerreichbar gewesen, weil sich nur abhaken lässt,
was in der Abarbeitung steht. Der Test hat das sofort gezeigt.
- **Die Regel liegt in `src/lib/geltende-abschnitte.ts`**, nicht in einem der Lesemodelle: drei
Schichten brauchen sie — der Dienst beim Start und beim Abschluss, und die Lesemodelle der
Abarbeitung und der Druckansicht. Lebte sie in einem Lesemodell, importierte ein Dienst daraus, und
das wäre eine Abhängigkeit in die falsche Richtung.
- **Ein Baustein-Abschnitt lässt sich genauso einschränken** wie jeder andere: die Einschränkung
gehört zur Vorlage, nicht zum Baustein.
- **Eigener Numerusfehler beim Durchklicken gefunden:** „Ein Abschnitt gilt … und *bleiben* weg."
Korrigiert — alles, was ein Mensch liest, schreibt sich richtig (CLAUDE.md).
+2 -2
View File
@@ -35,9 +35,9 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
| #028 | [M15 – Quellenarten und Microsoft-Graph-Quelle](028-graph-quelle.md) | offen | v0.2.0 | 10 h | – | | #028 | [M15 – Quellenarten und Microsoft-Graph-Quelle](028-graph-quelle.md) | offen | v0.2.0 | 10 h | – |
| #029 | [M16 – SCIM-Endpunkt als Alternative zum Abgleich](029-scim-endpunkt.md) | offen | v0.2.0 | 12 h | – | | #029 | [M16 – SCIM-Endpunkt als Alternative zum Abgleich](029-scim-endpunkt.md) | offen | v0.2.0 | 12 h | – |
| #030 | [M17 – Wiederverwendbare Bausteine](030-bausteine.md) | erledigt | v0.2.0 | 12 h | 2 h 25 min | | #030 | [M17 – Wiederverwendbare Bausteine](030-bausteine.md) | erledigt | v0.2.0 | 12 h | 2 h 25 min |
| #031 | [M18 – Abteilungsspezifische Zusatzabschnitte](031-abteilungsabschnitte.md) | offen | v0.2.0 | 8 h | – | | #031 | [M18 – Abteilungsspezifische Zusatzabschnitte](031-abteilungsabschnitte.md) | erledigt | v0.2.0 | 8 h | 1 h 35 min |
| #032 | [M19 – Anhänge und Fotos an Schritten](032-anhaenge.md) | offen | v0.2.0 | 10 h | – | | #032 | [M19 – Anhänge und Fotos an Schritten](032-anhaenge.md) | offen | v0.2.0 | 10 h | – |
| #033 | [M20 – Live-Aktualisierung per SSE statt Polling](033-sse-live.md) | offen | v0.2.0 | 6 h | – | | #033 | [M20 – Live-Aktualisierung per SSE statt Polling](033-sse-live.md) | offen | v0.2.0 | 6 h | – |
| #034 | [M21 – Benachrichtigungen per Mail und Webhook](034-benachrichtigungen.md) | offen | v0.2.0 | 10 h | – | | #034 | [M21 – Benachrichtigungen per Mail und Webhook](034-benachrichtigungen.md) | offen | v0.2.0 | 10 h | – |
| #035 | [M22 – Mitarbeiter-Selbstauskunft](035-selbstauskunft.md) | offen | v0.2.0 | 8 h | – | | #035 | [M22 – Mitarbeiter-Selbstauskunft](035-selbstauskunft.md) | offen | v0.2.0 | 8 h | – |
| | **Summe** | | | **229 h** | **43 h 45 min** | | | **Summe** | | | **229 h** | **45 h 20 min** |
@@ -0,0 +1,30 @@
-- ---------------------------------------------------------------------------
-- HANDGESCHRIEBEN. Prisma erzeugt hier zusaetzlich Loeschbefehle fuer die
-- zusammengesetzten Fremdschluessel (Mandantengrenze), die Trigramm-Indizes
-- (Archivsuche), die partiellen eindeutigen Indizes und die Trigger auf
-- veroeffentlichten Versionen: es kennt sie nicht, weil sie sich im Schema nicht
-- ausdruecken lassen. Diese Loeschbefehle sind bewusst gestrichen.
--
-- tests/datenbankregeln.test.ts prueft, dass alle diese Objekte vorhanden sind –
-- ein versehentlich stehengelassener Loeschbefehl faellt dort sofort auf.
-- ---------------------------------------------------------------------------
-- Abteilungsspezifische Zusatzabschnitte (#031).
--
-- Beim Onboarding in der Buchhaltung gehoeren Schritte dazu, die sonst niemand
-- braucht. Bisher hiess die Wahl: eine Vorlage je Abteilung pflegen, oder
-- Schritte, die fuer die meisten "nicht zutreffend" sind.
--
-- Namen und nicht Kennungen: der Abschnitt gehoert zur Version und muss ein
-- Umbenennen oder Archivieren einer Abteilung ueberleben.
ALTER TABLE "VorlagenAbschnitt"
ADD COLUMN "nurFuerAbteilungen" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
-- Welche Abschnitte fuer einen Lauf gelten, beim Start festgeschrieben.
--
-- Leer heisst **alle**. Bestehende Laeufe bekommen damit genau ihr bisheriges
-- Verhalten, ohne Nachfuehrung: sie zeigen weiter jeden Abschnitt ihrer Version.
-- Festgeschrieben und nicht berechnet, weil ein archivierter Lauf genau die
-- Schritte zeigen muss, die damals abgearbeitet wurden.
ALTER TABLE "Lauf"
ADD COLUMN "geltendeAbschnitte" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
+19 -5
View File
@@ -464,10 +464,16 @@ model VorlagenVariable {
} }
model VorlagenAbschnitt { model VorlagenAbschnitt {
id String @id @default(cuid()) id String @id @default(cuid())
versionId String versionId String
titel String titel String
position Int @default(0) position Int @default(0)
/// 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, ohne ins Leere
/// zu zeigen — dieselbe Entscheidung wie bei `Mitarbeiter.abteilung`.
nurFuerAbteilungen String[] @default([])
/// Verweis auf einen wiederverwendbaren Baustein (#030). /// Verweis auf einen wiederverwendbaren Baustein (#030).
/// ///
/// In einem **Entwurf** heisst das: beim Veroeffentlichen hier die Schritte des /// In einem **Entwurf** heisst das: beim Veroeffentlichen hier die Schritte des
@@ -475,7 +481,7 @@ model VorlagenAbschnitt {
/// Feld: diese Schritte stammen von dort — es ist dann nur noch Herkunft. /// Feld: diese Schritte stammen von dort — es ist dann nur noch Herkunft.
/// Welche der beiden Lesarten gilt, sagt der Status der Version; ein zweites /// Welche der beiden Lesarten gilt, sagt der Status der Version; ein zweites
/// Feld dafuer waere eine zweite Wahrheit. /// Feld dafuer waere eine zweite Wahrheit.
bausteinVersionId String? bausteinVersionId String?
version VorlagenVersion @relation(fields: [versionId], references: [id], onDelete: Cascade) version VorlagenVersion @relation(fields: [versionId], references: [id], onDelete: Cascade)
bausteinVersion BausteinVersion? @relation(fields: [bausteinVersionId], references: [id], onDelete: Restrict) bausteinVersion BausteinVersion? @relation(fields: [bausteinVersionId], references: [id], onDelete: Restrict)
@@ -591,6 +597,14 @@ model Lauf {
ticket String? ticket String?
/// Werte der VorlagenVariablen, beim Start erfasst. /// Werte der VorlagenVariablen, beim Start erfasst.
variablen Json @default("{}") variablen Json @default("{}")
/// Welche Abschnitte fuer diesen Lauf gelten, beim Start festgeschrieben (#031).
///
/// **Leer heisst: alle.** So bleiben Laeufe von vor dieser Fassung unberuehrt.
/// Festgeschrieben und nicht bei jedem Aufruf aus `abteilungId` berechnet: ein
/// archivierter Lauf muss genau die Schritte zeigen, die damals abgearbeitet
/// wurden, und ein spaeterer Abteilungswechsel darf keinen Schritt entfernen,
/// an dem schon eine Notiz haengt.
geltendeAbschnitte String[] @default([])
status LaufStatus @default(OFFEN) status LaufStatus @default(OFFEN)
zugewiesenAnId String? zugewiesenAnId String?
faelligAm DateTime? faelligAm DateTime?
@@ -3,6 +3,7 @@ import { Editor } from "@/components/vorlagen/editor";
import { VersionStatus } from "@/generated/prisma/enums"; import { VersionStatus } from "@/generated/prisma/enums";
import { darf } from "@/server/auth/rollen"; import { darf } from "@/server/auth/rollen";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { abteilungsListe } from "@/server/queries/abteilungen";
import { einsetzbareBausteine } from "@/server/queries/bausteine"; import { einsetzbareBausteine } from "@/server/queries/bausteine";
import { versionMitAufbau } from "@/server/queries/vorlagen"; 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. * und das waere eine Falschaussage ueber die Herkunft.
*/ */
const bausteine = await einsetzbareBausteine(); 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 ( return (
<Editor <Editor
@@ -50,6 +62,7 @@ export default async function VersionSeite({ params }: { params: Promise<{ versi
lesend={veroeffentlicht || !darfBearbeiten} lesend={veroeffentlicht || !darfBearbeiten}
darfBearbeiten={darfBearbeiten} darfBearbeiten={darfBearbeiten}
bausteine={bausteine} bausteine={bausteine}
abteilungen={abteilungen}
// Auf dem Server formatiert: im Browser waere es eine zweite Uhrzeit und // Auf dem Server formatiert: im Browser waere es eine zweite Uhrzeit und
// damit ein Unterschied zwischen Vorrendern und Hydrieren. // damit ein Unterschied zwischen Vorrendern und Hydrieren.
beispielDatum={new Date().toLocaleDateString("de-DE", { beispielDatum={new Date().toLocaleDateString("de-DE", {
+11 -3
View File
@@ -59,6 +59,9 @@ export function BausteinEditor({ entwurf }: { entwurf: BausteinEntwurf }) {
abschnitte: [ abschnitte: [
{ {
titel: entwurf.titel, 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) => ({ schritte: entwurf.schritte.map((schritt) => ({
titel: schritt.titel, titel: schritt.titel,
...(schritt.hilfetext === null ? {} : { hilfetext: schritt.hilfetext }), ...(schritt.hilfetext === null ? {} : { hilfetext: schritt.hilfetext }),
@@ -91,11 +94,16 @@ export function BausteinEditor({ entwurf }: { entwurf: BausteinEntwurf }) {
); );
const jetzt = JSON.stringify( 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( const vorher = JSON.stringify(
alsStruktur({ variablen: [], abschnitte: [anfang ?? { kennung: "x", titel: "", schritte: [] }] }) alsStruktur({
.abschnitte[0], variablen: [],
abschnitte: [anfang ?? { kennung: "x", titel: "", schritte: [], nurFuerAbteilungen: [] }],
}).abschnitte[0],
); );
const geaendert = jetzt !== vorher; const geaendert = jetzt !== vorher;
const voll = schritte.length >= GRENZEN.schritteJeAbschnitt; const voll = schritte.length >= GRENZEN.schritteJeAbschnitt;
+52 -1
View File
@@ -51,6 +51,8 @@ export function LaufStarten({
const [mitarbeiterId, setzeMitarbeiter] = useState(vorbelegung.mitarbeiterId ?? ""); const [mitarbeiterId, setzeMitarbeiter] = useState(vorbelegung.mitarbeiterId ?? "");
const [freitext, setzeFreitext] = useState(""); const [freitext, setzeFreitext] = useState("");
const [kundeId, setzeKunde] = 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 [variablen, setzeVariablen] = useState<Record<string, string>>({});
const vorlage = useMemo( const vorlage = useMemo(
@@ -58,6 +60,30 @@ export function LaufStarten({
[vorlagen, versionId], [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. // Nach dem Anlegen direkt in den Lauf – wer ihn startet, will ihn abarbeiten.
useEffect(() => { useEffect(() => {
if (ergebnis.laufId) router.push(`/laeufe/${ergebnis.laufId}`); if (ergebnis.laufId) router.push(`/laeufe/${ergebnis.laufId}`);
@@ -284,7 +310,12 @@ export function LaufStarten({
</label> </label>
<label className="flex flex-col gap-1.5 text-sm"> <label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Abteilung</span> <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> <option value="">keine</option>
{auswahl.abteilungen.map((abteilung) => ( {auswahl.abteilungen.map((abteilung) => (
<option key={abteilung.id} value={abteilung.id}> <option key={abteilung.id} value={abteilung.id}>
@@ -292,6 +323,26 @@ export function LaufStarten({
</option> </option>
))} ))}
</select> </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>
<label className="flex flex-col gap-1.5 text-sm"> <label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Zuweisen an</span> <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 { useState } from "react";
import { Blocks } from "lucide-react"; import { Blocks } from "lucide-react";
import { AbteilungenWaehlen } from "@/components/vorlagen/abteilungen-waehlen";
import { KlappKarte, LoeschKnopf, TextKnopf, Textfeld } from "@/components/vorlagen/editor-felder"; import { KlappKarte, LoeschKnopf, TextKnopf, Textfeld } from "@/components/vorlagen/editor-felder";
import { SortierBereich, SortierZeile, VerschiebeKnoepfe } from "@/components/vorlagen/editor-sortieren"; import { SortierBereich, SortierZeile, VerschiebeKnoepfe } from "@/components/vorlagen/editor-sortieren";
import { SchrittKarte } from "@/components/vorlagen/editor-schritt"; import { SchrittKarte } from "@/components/vorlagen/editor-schritt";
@@ -30,6 +31,7 @@ export function AbschnittKarte({
index, index,
anzahl, anzahl,
lesend, lesend,
abteilungen,
zuletztAngelegt, zuletztAngelegt,
anfasser, anfasser,
aendern, aendern,
@@ -44,6 +46,8 @@ export function AbschnittKarte({
index: number; index: number;
anzahl: number; anzahl: number;
lesend: boolean; lesend: boolean;
/** Die gepflegten Abteilungsnamen – für die Einschränkung (#031). */
abteilungen: readonly string[];
/** Kennung der zuletzt angelegten Zeile — die wird aufgeklappt gezeigt. */ /** Kennung der zuletzt angelegten Zeile — die wird aufgeklappt gezeigt. */
zuletztAngelegt: string | null; zuletztAngelegt: string | null;
anfasser: React.ReactNode; anfasser: React.ReactNode;
@@ -83,6 +87,11 @@ export function AbschnittKarte({
Baustein Baustein
</span> </span>
) : null} ) : 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={ knoepfe={
@@ -139,6 +148,17 @@ export function AbschnittKarte({
/> />
) : null} ) : 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 ? ( {baustein ? null : anzahlSchritte === 0 ? (
<p className="text-sm text-nebentext">Noch kein Schritt in diesem Abschnitt.</p> <p className="text-sm text-nebentext">Noch kein Schritt in diesem Abschnitt.</p>
) : ( ) : (
+4
View File
@@ -83,6 +83,7 @@ export function Editor({
darfBearbeiten, darfBearbeiten,
beispielDatum, beispielDatum,
bausteine = [], bausteine = [],
abteilungen = [],
}: { }: {
version: EditorVersion; version: EditorVersion;
lesend: boolean; lesend: boolean;
@@ -91,6 +92,8 @@ export function Editor({
beispielDatum: string; beispielDatum: string;
/** Veröffentlichte Bausteine, die sich einsetzen lassen (#030). */ /** Veröffentlichte Bausteine, die sich einsetzen lassen (#030). */
bausteine?: readonly EinsetzbarerBaustein[]; bausteine?: readonly EinsetzbarerBaustein[];
/** Gepflegte Abteilungsnamen – für die Einschränkung je Abschnitt (#031). */
abteilungen?: readonly string[];
}) { }) {
const router = useRouter(); const router = useRouter();
const [aufbau, setzeAufbau] = useState<EditorAufbau>(() => const [aufbau, setzeAufbau] = useState<EditorAufbau>(() =>
@@ -336,6 +339,7 @@ export function Editor({
abschnitt={abschnitt} abschnitt={abschnitt}
index={index} index={index}
anzahl={aufbau.abschnitte.length} anzahl={aufbau.abschnitte.length}
abteilungen={abteilungen}
lesend={lesend} lesend={lesend}
zuletztAngelegt={zuletztAngelegt} zuletztAngelegt={zuletztAngelegt}
anfasser={anfasser} anfasser={anfasser}
+12 -1
View File
@@ -41,6 +41,8 @@ export type EditorAbschnitt = {
readonly kennung: string; readonly kennung: string;
readonly titel: string; readonly titel: string;
readonly schritte: readonly EditorSchritt[]; 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). * Gesetzt, wenn dieser Abschnitt ein eingesetzter **Baustein** ist (#030).
* *
@@ -216,7 +218,7 @@ export function neuerSchritt(): EditorSchritt {
} }
export function neuerAbschnitt(): EditorAbschnitt { 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"), kennung: neueKennung("abschnitt"),
titel: baustein.titel, titel: baustein.titel,
baustein: { versionId: baustein.bausteinVersionId, name: baustein.name, version: baustein.version }, 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. // Platzhalter, damit die Anzahl stimmt. Gespeichert werden sie nicht.
schritte: Array.from({ length: baustein.anzahlSchritte }, () => neuerSchritt()), schritte: Array.from({ length: baustein.anzahlSchritte }, () => neuerSchritt()),
}; };
@@ -302,6 +307,7 @@ export function alsEditorAufbau(struktur: Struktur): EditorAufbau {
kennung: neueKennung("abschnitt"), kennung: neueKennung("abschnitt"),
// Die Nummer gehoert zur Position, nicht in den Titel — siehe titelOhneNummer. // Die Nummer gehoert zur Position, nicht in den Titel — siehe titelOhneNummer.
titel: titelOhneNummer(abschnitt.titel), titel: titelOhneNummer(abschnitt.titel),
nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen],
// Den Namen und die Fassung kennt die Struktur nicht; die Seite reicht sie // Den Namen und die Fassung kennt die Struktur nicht; die Seite reicht sie
// getrennt nach und setzt sie ueber `bausteineAnreichern` ein (#030). // getrennt nach und setzt sie ueber `bausteineAnreichern` ein (#030).
...(abschnitt.bausteinVersionId === undefined ...(abschnitt.bausteinVersionId === undefined
@@ -341,6 +347,11 @@ export function alsStruktur(aufbau: EditorAufbau): Struktur {
abschnitte: aufbau.abschnitte.map((abschnitt) => ({ abschnitte: aufbau.abschnitte.map((abschnitt) => ({
titel: abschnitt.titel, titel: abschnitt.titel,
...(abschnitt.baustein === undefined ? {} : { bausteinVersionId: abschnitt.baustein.versionId }), ...(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 * Ein Baustein-Abschnitt gibt **keine** Schritte mit: sie kommen beim
* Veroeffentlichen aus dem Baustein. Die Schritte im Zustand sind nur * 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. * die sich nicht veroeffentlichen laesst.
*/ */
bausteinVersionId: leerAlsUndefiniert(z.string().trim().max(60)), 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 schritte: z
.array(SchrittSchema) .array(SchrittSchema)
.max( .max(
+6 -1
View File
@@ -1,5 +1,6 @@
import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums"; import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { nurGeltende } from "@/lib/geltende-abschnitte";
import { platzhalterErsetzen } from "@/lib/platzhalter-werte"; import { platzhalterErsetzen } from "@/lib/platzhalter-werte";
import { kundenEbeneSichtbar, sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant"; import { kundenEbeneSichtbar, sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
import { kommentareDesLaufs, type KommentarZeile } from "@/server/queries/laeufe"; import { kommentareDesLaufs, type KommentarZeile } from "@/server/queries/laeufe";
@@ -163,6 +164,8 @@ export async function laufZumDrucken(
zugewiesenAn: { select: { name: true } }, zugewiesenAn: { select: { name: true } },
abgeschlossenVon: { select: { name: true } }, abgeschlossenVon: { select: { name: true } },
archiviertVon: { select: { name: true } }, archiviertVon: { select: { name: true } },
// Beim Start festgeschriebene Abschnittsauswahl (#031).
geltendeAbschnitte: true,
vorlagenVersion: { vorlagenVersion: {
select: { select: {
version: true, version: true,
@@ -219,7 +222,9 @@ export async function laufZumDrucken(
let anzahlSchritte = 0; let anzahlSchritte = 0;
let anzahlVomTisch = 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) => { const schritte = abschnitt.schritte.map((schritt) => {
nummer += 1; nummer += 1;
anzahlSchritte += 1; anzahlSchritte += 1;
+25 -1
View File
@@ -1,6 +1,7 @@
import type { Prisma } from "@/generated/prisma/client"; import type { Prisma } from "@/generated/prisma/client";
import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums"; import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { nurGeltende } from "@/lib/geltende-abschnitte";
import { platzhalterErsetzen } from "@/lib/platzhalter-werte"; import { platzhalterErsetzen } from "@/lib/platzhalter-werte";
import { platzhalterAusserhalbAnfuehrungszeichen, skriptBauen, wertMaskieren, type Shell } from "@/lib/shell"; import { platzhalterAusserhalbAnfuehrungszeichen, skriptBauen, wertMaskieren, type Shell } from "@/lib/shell";
import { STANDARD_SHELL } from "@/lib/editor-aufbau"; import { STANDARD_SHELL } from "@/lib/editor-aufbau";
@@ -236,6 +237,8 @@ export async function laufZumAbarbeiten(
const lauf = await prisma.lauf.findUniqueOrThrow({ const lauf = await prisma.lauf.findUniqueOrThrow({
where: { id: laufId }, where: { id: laufId },
select: { select: {
// Beim Start festgeschriebene Abschnittsauswahl (#031).
geltendeAbschnitte: true,
vorlagenVersion: { vorlagenVersion: {
select: { select: {
abschnitte: { abschnitte: {
@@ -294,7 +297,8 @@ export async function laufZumAbarbeiten(
let anzahlPflicht = 0; let anzahlPflicht = 0;
let anzahlPflichtOffen = 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 befehle: { titel: string; shell: Shell; befehl: string }[] = [];
const schritte = abschnitt.schritte.map((schritt) => { const schritte = abschnitt.schritte.map((schritt) => {
@@ -599,6 +603,17 @@ export type StartbareVorlage = {
readonly optionen: readonly string[]; readonly optionen: readonly string[];
readonly pflicht: boolean; 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, 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, version: version.version,
kategorie: vorlage.kategorie, kategorie: vorlage.kategorie,
variablen: version.variablen, variablen: version.variablen,
abteilungsAbschnitte: version.abschnitte.map((abschnitt) => ({
titel: abschnitt.titel,
abteilungen: abschnitt.nurFuerAbteilungen,
})),
}); });
} }
return startbare; return startbare;
+2
View File
@@ -266,6 +266,8 @@ export async function versionMitAufbau(
})), })),
abschnitte: version.abschnitte.map((abschnitt) => ({ abschnitte: version.abschnitte.map((abschnitt) => ({
titel: abschnitt.titel, titel: abschnitt.titel,
nurFuerAbteilungen: abschnitt.nurFuerAbteilungen,
...(abschnitt.bausteinVersionId === null ? {} : { bausteinVersionId: abschnitt.bausteinVersionId }),
schritte: abschnitt.schritte.map((schritt) => ({ schritte: abschnitt.schritte.map((schritt) => ({
titel: schritt.titel, titel: schritt.titel,
...(schritt.hilfetext === null ? {} : { hilfetext: schritt.hilfetext }), ...(schritt.hilfetext === null ? {} : { hilfetext: schritt.hilfetext }),
+70 -5
View File
@@ -1,6 +1,7 @@
import { Prisma, type Lauf } from "@/generated/prisma/client"; import { Prisma, type Lauf } from "@/generated/prisma/client";
import { LaufStatus, SchrittZustand, VariablenTyp } from "@/generated/prisma/enums"; import { LaufStatus, SchrittZustand, VariablenTyp } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { geltendeAbschnitteErmitteln, nurGeltende } from "@/lib/geltende-abschnitte";
import { darf } from "@/server/auth/rollen"; import { darf } from "@/server/auth/rollen";
import { import {
MandantFehler, MandantFehler,
@@ -190,6 +191,13 @@ type VersionFuerLauf = {
readonly pflicht: boolean; readonly pflicht: boolean;
}[]; }[];
readonly schrittIds: readonly string[]; 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: { abschnitte: {
orderBy: { position: "asc" }, 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, vorlagenName: version.vorlage.name,
variablen: version.variablen, variablen: version.variablen,
schrittIds: version.abschnitte.flatMap((abschnitt) => abschnitt.schritte.map((s) => s.id)), 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 mitarbeiterName: string | null;
readonly geraetId: string | null; readonly geraetId: string | null;
readonly abteilungId: string | null; readonly abteilungId: string | null;
/** Name der gewaehlten Abteilung – fuer die Abschnittsauswahl (#031). */
readonly abteilungsName: string | null;
/** Nur fuer den Titelvorschlag. */ /** Nur fuer den Titelvorschlag. */
readonly geraetName: string | null; readonly geraetName: string | null;
readonly personName: string | null; readonly personName: string | null;
@@ -310,6 +331,7 @@ async function bezuegePruefen(
} }
const abteilungId = text(eingabe.abteilungId); const abteilungId = text(eingabe.abteilungId);
let abteilungsName: string | null = null;
if (abteilungId) { if (abteilungId) {
const abteilung = await prisma.abteilung.findUnique({ const abteilung = await prisma.abteilung.findUnique({
where: { id: abteilungId }, where: { id: abteilungId },
@@ -322,9 +344,18 @@ async function bezuegePruefen(
if (abteilung.archiviert) { if (abteilung.archiviert) {
throw new LaufFehler(`Die Abteilung „${abteilung.name}“ ist 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, 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) => { return prisma.$transaction(async (tx) => {
const lauf = await tx.lauf.create({ const lauf = await tx.lauf.create({
data: { data: {
@@ -457,12 +510,17 @@ export async function laufAnlegen(eingabe: NeuerLauf, benutzer: HandelnderBenutz
zugewiesenAnId, zugewiesenAnId,
faelligAm: eingabe.faelligAm ?? null, faelligAm: eingabe.faelligAm ?? null,
erstelltVonId: benutzer.id, 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({ await tx.schrittergebnis.createMany({
data: version.schrittIds.map((vorlagenSchrittId) => ({ data: anzulegen.map((vorlagenSchrittId) => ({
laufId: lauf.id, laufId: lauf.id,
vorlagenSchrittId, vorlagenSchrittId,
zustand: SchrittZustand.OFFEN, zustand: SchrittZustand.OFFEN,
@@ -605,11 +663,13 @@ export async function offenePflichtschritte(
const lauf = await tx.lauf.findUniqueOrThrow({ const lauf = await tx.lauf.findUniqueOrThrow({
where: { id: laufId }, where: { id: laufId },
select: { select: {
geltendeAbschnitte: true,
vorlagenVersion: { vorlagenVersion: {
select: { select: {
abschnitte: { abschnitte: {
orderBy: { position: "asc" }, orderBy: { position: "asc" },
select: { select: {
id: true,
titel: true, titel: true,
schritte: { schritte: {
where: { pflicht: true }, where: { pflicht: true },
@@ -626,7 +686,12 @@ export async function offenePflichtschritte(
const zustaende = new Map(lauf.ergebnisse.map((e) => [e.vorlagenSchrittId, e.zustand])); const zustaende = new Map(lauf.ergebnisse.map((e) => [e.vorlagenSchrittId, e.zustand]));
const offen: OffenerPflichtschritt[] = []; 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) { for (const schritt of abschnitt.schritte) {
// Ohne Ergebniszeile gilt OFFEN – ein Schritt ohne Eintrag ist nicht erledigt. // Ohne Ergebniszeile gilt OFFEN – ein Schritt ohne Eintrag ist nicht erledigt.
const zustand = zustaende.get(schritt.id) ?? SchrittZustand.OFFEN; 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) => ({ abschnitte: version.abschnitte.map((abschnitt) => ({
titel: abschnitt.titel, 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), 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 // Ein Trigger laesst nur veroeffentlichte Baustein-Versionen zu; der
// Dienst prueft dasselbe mit besserer Meldung (#030). // Dienst prueft dasselbe mit besserer Meldung (#030).
bausteinVersionId: abschnitt.bausteinVersionId ?? null, bausteinVersionId: abschnitt.bausteinVersionId ?? null,
nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen],
}, },
}); });
if (abschnitt.schritte.length > 0) { if (abschnitt.schritte.length > 0) {
+265
View File
@@ -0,0 +1,265 @@
import { beforeEach, describe, expect, it } from "vitest";
import { LaufStatus, SchrittZustand, VersionStatus } from "@/generated/prisma/enums";
import { LaufFehler, laufAnlegen, laufAbschliessen } from "@/server/services/laeufe";
import { geltendeAbschnitteErmitteln, nurGeltende } from "@/lib/geltende-abschnitte";
import { laufZumAbarbeiten } from "@/server/queries/laeufe";
import { laufZumDrucken } from "@/server/queries/druck";
import { schrittergebnisSetzen } from "@/server/services/schrittergebnisse";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Abteilungsspezifische Zusatzabschnitte (#031).
*
* Die Auswahl wird **beim Start festgeschrieben**. Alles hier dreht sich darum:
* dass sie stimmt, dass sie sich danach nicht mehr aendert, dass ein
* ausgefilterter Pflichtschritt den Abschluss nicht blockiert, und dass die
* Druckansicht genau dasselbe zeigt wie die Abarbeitung.
*/
describe("Auswahlregel", () => {
const abschnitte = [
{ id: "a-alle", nurFuerAbteilungen: [] },
{ id: "a-buch", nurFuerAbteilungen: ["Buchhaltung"] },
{ id: "a-beide", nurFuerAbteilungen: ["Buchhaltung", "Vertrieb"] },
];
it("nimmt uneingeschränkte Abschnitte immer", () => {
expect(geltendeAbschnitteErmitteln(abschnitte, null)).toEqual(["a-alle"]);
});
it("nimmt den Abschnitt der passenden Abteilung dazu", () => {
expect(geltendeAbschnitteErmitteln(abschnitte, "Buchhaltung")).toEqual(["a-alle", "a-buch", "a-beide"]);
expect(geltendeAbschnitteErmitteln(abschnitte, "Vertrieb")).toEqual(["a-alle", "a-beide"]);
});
it("vergleicht ohne Rücksicht auf Groß- und Kleinschreibung und Ränder", () => {
// Die Namen kommen aus zwei Quellen – der gepflegten Abteilung und dem
// Freitextfeld im Editor. „buchhaltung“ ist dieselbe Abteilung.
expect(geltendeAbschnitteErmitteln(abschnitte, " buchhaltung ")).toContain("a-buch");
});
it("lässt eine unbekannte Abteilung nur die uneingeschränkten Abschnitte", () => {
expect(geltendeAbschnitteErmitteln(abschnitte, "Einkauf")).toEqual(["a-alle"]);
});
});
describe("nurGeltende", () => {
const abschnitte = [{ id: "eins" }, { id: "zwei" }, { id: "drei" }];
it("gibt bei leerer Liste alles zurück – so bleiben alte Läufe unberührt", () => {
expect(nurGeltende(abschnitte, [])).toEqual(abschnitte);
});
it("filtert und behält die Reihenfolge der Version", () => {
expect(nurGeltende(abschnitte, ["drei", "eins"])).toEqual([{ id: "eins" }, { id: "drei" }]);
});
});
async function aufbau(
abschnitte: readonly {
readonly titel: string;
readonly nurFuerAbteilungen?: readonly string[];
readonly schritte: readonly { readonly titel: string; readonly pflicht?: boolean }[];
}[],
) {
const kunde = await testPrisma.kunde.create({
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({ data: { id: "global" } });
const admin = await testPrisma.benutzer.create({
data: { email: "admin@firma.example", name: "Admin", rolle: "ADMIN" },
});
const buchhaltung = await testPrisma.abteilung.create({
data: { kundeId: kunde.id, name: "Buchhaltung" },
});
const vertrieb = await testPrisma.abteilung.create({
data: { kundeId: kunde.id, name: "Vertrieb" },
});
const vorlage = await testPrisma.vorlage.create({ data: { name: "Onboarding" } });
const version = await testPrisma.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF },
});
for (const [position, abschnitt] of abschnitte.entries()) {
const angelegt = await testPrisma.vorlagenAbschnitt.create({
data: {
versionId: version.id,
titel: abschnitt.titel,
position,
nurFuerAbteilungen: [...(abschnitt.nurFuerAbteilungen ?? [])],
},
});
await testPrisma.vorlagenSchritt.createMany({
data: abschnitt.schritte.map((schritt, stelle) => ({
abschnittId: angelegt.id,
titel: schritt.titel,
pflicht: schritt.pflicht ?? false,
position: stelle,
})),
});
}
await testPrisma.vorlagenVersion.update({
where: { id: version.id },
data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() },
});
return {
kunde,
admin: { id: admin.id, rolle: admin.rolle },
buchhaltung,
vertrieb,
versionId: version.id,
};
}
const VORLAGE = [
{ titel: "Vorbereitung", schritte: [{ titel: "Papiere", pflicht: true }] },
{
titel: "Buchhaltung",
nurFuerAbteilungen: ["Buchhaltung"],
schritte: [{ titel: "DATEV einrichten", pflicht: true }],
},
{ titel: "Abschluss", schritte: [{ titel: "Übergabe", pflicht: true }] },
] as const;
describe.runIf(datenbankVerfuegbar)("Abteilungsabschnitte am Lauf", () => {
beforeEach(async () => {
await datenbankLeeren();
});
it("nimmt den Buchhaltungs-Abschnitt für einen Lauf der Buchhaltung", async () => {
const { admin, buchhaltung, versionId } = await aufbau(VORLAGE);
const lauf = await laufAnlegen({ vorlagenVersionId: versionId, abteilungId: buchhaltung.id }, admin);
const daten = await laufZumAbarbeiten(lauf.id, admin);
expect(daten?.abschnitte.map((abschnitt) => abschnitt.titel)).toEqual([
"Vorbereitung",
"Buchhaltung",
"Abschluss",
]);
// Die Nummerierung zählt nur die geltenden Schritte.
expect(daten?.abschnitte.flatMap((a) => a.schritte.map((s) => s.nummer))).toEqual([1, 2, 3]);
});
it("lässt ihn für einen Lauf des Vertriebs weg", async () => {
const { admin, vertrieb, versionId } = await aufbau(VORLAGE);
const lauf = await laufAnlegen({ vorlagenVersionId: versionId, abteilungId: vertrieb.id }, admin);
const daten = await laufZumAbarbeiten(lauf.id, admin);
expect(daten?.abschnitte.map((abschnitt) => abschnitt.titel)).toEqual(["Vorbereitung", "Abschluss"]);
expect(daten?.anzahlPflicht).toBe(2);
});
it("gibt einem Lauf ohne Abteilung nur die uneingeschränkten Abschnitte", async () => {
// Ein Firewall-Rollout hat keine Abteilung, und das ist kein Mangel.
const { admin, versionId } = await aufbau(VORLAGE);
const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, admin);
const daten = await laufZumAbarbeiten(lauf.id, admin);
expect(daten?.abschnitte.map((abschnitt) => abschnitt.titel)).toEqual(["Vorbereitung", "Abschluss"]);
});
it("legt nur für die geltenden Schritte ein Schrittergebnis an", async () => {
const { admin, vertrieb, versionId } = await aufbau(VORLAGE);
const lauf = await laufAnlegen({ vorlagenVersionId: versionId, abteilungId: vertrieb.id }, admin);
const ergebnisse = await testPrisma.schrittergebnis.findMany({
where: { laufId: lauf.id },
include: { vorlagenSchritt: true },
});
expect(ergebnisse.map((e) => e.vorlagenSchritt.titel).sort()).toEqual(["Papiere", "Übergabe"]);
});
it("ändert die Abschnitte nicht, wenn später die Abteilung des Laufs wechselt", async () => {
/*
* Der Grund für das Festschreiben: ein archivierter Lauf muss genau die
* Schritte zeigen, die damals abgearbeitet wurden. Ein Abteilungswechsel
* darf keinen Schritt entfernen, an dem schon eine Notiz hängt.
*/
const { admin, buchhaltung, vertrieb, versionId } = await aufbau(VORLAGE);
const lauf = await laufAnlegen({ vorlagenVersionId: versionId, abteilungId: buchhaltung.id }, admin);
expect((await laufZumAbarbeiten(lauf.id, admin))?.abschnitte).toHaveLength(3);
await testPrisma.lauf.update({ where: { id: lauf.id }, data: { abteilungId: vertrieb.id } });
const nachher = await laufZumAbarbeiten(lauf.id, admin);
expect(nachher?.abschnitte.map((abschnitt) => abschnitt.titel)).toEqual([
"Vorbereitung",
"Buchhaltung",
"Abschluss",
]);
});
it("lässt den Abschluss zu, obwohl ein ausgefilterter Pflichtschritt existiert", async () => {
// Ohne diese Regel wäre der Abschluss unerreichbar: der Pflichtschritt der
// Buchhaltung ließe sich in einem Vertriebs-Lauf nie abhaken.
const { admin, vertrieb, versionId } = await aufbau(VORLAGE);
const lauf = await laufAnlegen({ vorlagenVersionId: versionId, abteilungId: vertrieb.id }, admin);
const ergebnisse = await testPrisma.schrittergebnis.findMany({ where: { laufId: lauf.id } });
for (const ergebnis of ergebnisse) {
await schrittergebnisSetzen(
{
laufId: lauf.id,
vorlagenSchrittId: ergebnis.vorlagenSchrittId,
zustand: SchrittZustand.ERLEDIGT,
},
admin,
);
}
const abgeschlossen = await laufAbschliessen(lauf.id, admin);
expect(abgeschlossen.status).toBe(LaufStatus.ABGESCHLOSSEN);
});
it("zeigt in der Druckansicht genau dieselben Abschnitte", async () => {
const { admin, vertrieb, versionId } = await aufbau(VORLAGE);
const lauf = await laufAnlegen({ vorlagenVersionId: versionId, abteilungId: vertrieb.id }, admin);
const abarbeitung = await laufZumAbarbeiten(lauf.id, admin);
const druck = await laufZumDrucken(lauf.id, admin);
expect(druck?.abschnitte.map((abschnitt) => abschnitt.titel)).toEqual(
abarbeitung?.abschnitte.map((abschnitt) => abschnitt.titel),
);
expect(druck?.abschnitte.map((abschnitt) => abschnitt.titel)).not.toContain("Buchhaltung");
});
it("weist einen Lauf ab, für den kein Abschnitt übrig bleibt", async () => {
const { admin, versionId } = await aufbau([
{
titel: "Nur Buchhaltung",
nurFuerAbteilungen: ["Buchhaltung"],
schritte: [{ titel: "DATEV" }],
},
]);
await expect(laufAnlegen({ vorlagenVersionId: versionId }, admin)).rejects.toThrow(LaufFehler);
await expect(laufAnlegen({ vorlagenVersionId: versionId }, admin)).rejects.toThrow(/kein Abschnitt/);
});
it("lässt einen Lauf ohne Einschränkungen unverändert – geltendeAbschnitte zählt alle", async () => {
// Kein Sonderfall im Leseweg: die Liste enthält auch dann alle Abschnitte.
const { admin, versionId } = await aufbau([
{ titel: "Eins", schritte: [{ titel: "a" }] },
{ titel: "Zwei", schritte: [{ titel: "b" }] },
]);
const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, admin);
const zeile = await testPrisma.lauf.findUniqueOrThrow({ where: { id: lauf.id } });
expect(zeile.geltendeAbschnitte).toHaveLength(2);
expect((await laufZumAbarbeiten(lauf.id, admin))?.abschnitte).toHaveLength(2);
});
it("zeigt bei einem Lauf von vor dieser Fassung alle Abschnitte", async () => {
// `geltendeAbschnitte` leer heißt alle – so verhalten sich bestehende Läufe
// unverändert, ohne dass eine Migration Daten nachführen müsste.
const { admin, versionId } = await aufbau(VORLAGE);
const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, admin);
await testPrisma.lauf.update({ where: { id: lauf.id }, data: { geltendeAbschnitte: [] } });
const daten = await laufZumAbarbeiten(lauf.id, admin);
expect(daten?.abschnitte.map((abschnitt) => abschnitt.titel)).toEqual([
"Vorbereitung",
"Buchhaltung",
"Abschluss",
]);
});
});
+1
View File
@@ -151,6 +151,7 @@ describe("Zustand und Struktur", () => {
{ {
kennung: "a", kennung: "a",
titel: "Vorbereitung", titel: "Vorbereitung",
nurFuerAbteilungen: [],
schritte: [ schritte: [
{ {
kennung: "s", kennung: "s",