From b8166e10ea045f221d970da53f0793fe3dcb1dcc Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sun, 4 Oct 2026 20:04:14 +0200 Subject: [PATCH] feat(vorlagen): Abschnitte nur fuer bestimmte Abteilungen Beim Onboarding in der Buchhaltung gehoeren Schritte dazu, die sonst niemand braucht: DATEV, Drucker fuer Formulare, Bankportal. Bisher hiess die Wahl: eine Vorlage je Abteilung pflegen, oder Schritte, die fuer die meisten "nicht zutreffend" sind. Jeder Abschnitt kann jetzt nennen, fuer welche Abteilungen er gilt -- als **Namen**, nicht als Kennungen. Der Abschnitt gehoert zur Version und muss ein Umbenennen oder Archivieren einer Abteilung ueberleben; dieselbe Entscheidung wie bei Mitarbeiter.abteilung. Deshalb wandert die Einschraenkung auch durch Export und Import. Welche Abschnitte ein Lauf hat, wird beim Start festgeschrieben. Ein spaeterer Abteilungswechsel aendert ihn nicht -- sonst verschwaende ein Schritt, an dem schon eine Notiz haengt, und ein archivierter Lauf muss genau das zeigen, was damals abgearbeitet wurde. Leer heisst alle: Laeufe von vor dieser Fassung verhalten sich unveraendert, ohne dass eine Migration Daten nachfuehren muesste. Damit das eindeutig bleibt, wird ein Lauf abgewiesen, fuer den kein Abschnitt uebrig bleibt -- es gaebe ohnehin nichts abzuarbeiten. Die Abschlussregel zaehlt nur geltende Pflichtschritte. Vorher waere der Abschluss unerreichbar gewesen: abhaken laesst sich nur, was in der Abarbeitung steht. Der Test hat das sofort gezeigt. Die Regel liegt in src/lib, weil drei Schichten sie brauchen -- der Dienst beim Start und beim Abschluss, die Lesemodelle der Abarbeitung und des Drucks. In einem Lesemodell waere sie eine Abhaengigkeit in die falsche Richtung. Im gebauten Stand durchgeklickt: einschraenken, speichern, veroeffentlichen, zwei Laeufe mit verschiedenen Abteilungen starten -- 6 Abschnitte mit 23 Schritten fuer die Buchhaltung, 5 mit 20 fuer den Vertrieb. Dabei einen eigenen Numerusfehler im Hinweistext gefunden und behoben. Closes #51 --- CHANGELOG.md | 7 + docs/anleitung/02-laeufe-abarbeiten.md | 5 + docs/anleitung/04-vorlagen.md | 29 ++ docs/issues/031-abteilungsabschnitte.md | 56 ++-- docs/issues/README.md | 4 +- .../migration.sql | 30 ++ prisma/schema.prisma | 24 +- .../vorlagen/version/[versionId]/page.tsx | 13 + src/components/bausteine/baustein-editor.tsx | 14 +- src/components/laeufe/lauf-starten.tsx | 53 +++- .../vorlagen/abteilungen-waehlen.tsx | 137 +++++++++ src/components/vorlagen/editor-abschnitt.tsx | 20 ++ src/components/vorlagen/editor.tsx | 4 + src/lib/editor-aufbau.ts | 13 +- src/lib/geltende-abschnitte.ts | 51 ++++ src/lib/vorlagen-format.ts | 12 + src/server/queries/druck.ts | 7 +- src/server/queries/laeufe.ts | 26 +- src/server/queries/vorlagen.ts | 2 + src/server/services/laeufe.ts | 75 ++++- src/server/services/vorlagen-austausch.ts | 3 + src/server/services/vorlagen.ts | 1 + tests/abteilungsabschnitte.test.ts | 265 ++++++++++++++++++ tests/editor-aufbau.test.ts | 1 + 24 files changed, 815 insertions(+), 37 deletions(-) create mode 100644 prisma/migrations/20261004210000_abteilungsabschnitte/migration.sql create mode 100644 src/components/vorlagen/abteilungen-waehlen.tsx create mode 100644 src/lib/geltende-abschnitte.ts create mode 100644 tests/abteilungsabschnitte.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 32087f8..d076b4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,13 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). ### 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 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 diff --git a/docs/anleitung/02-laeufe-abarbeiten.md b/docs/anleitung/02-laeufe-abarbeiten.md index 269f3dd..33d98ac 100644 --- a/docs/anleitung/02-laeufe-abarbeiten.md +++ b/docs/anleitung/02-laeufe-abarbeiten.md @@ -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 [Kapitel 03](03-qr-tags.md). - **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. ![Der Start-Dialog mit Vorlage, Gerät, Person und den Angaben zum Lauf](bilder/lauf-starten-desktop.png) diff --git a/docs/anleitung/04-vorlagen.md b/docs/anleitung/04-vorlagen.md index 88474fc..018dba3 100644 --- a/docs/anleitung/04-vorlagen.md +++ b/docs/anleitung/04-vorlagen.md @@ -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 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 „Datensicherung prüfen“ steht in vier der sieben mitgelieferten Vorlagen, wortgleich. Wer den Ablauf diff --git a/docs/issues/031-abteilungsabschnitte.md b/docs/issues/031-abteilungsabschnitte.md index f9e7f81..37cd930 100644 --- a/docs/issues/031-abteilungsabschnitte.md +++ b/docs/issues/031-abteilungsabschnitte.md @@ -1,10 +1,10 @@ # #031 M18 – Abteilungsspezifische Zusatzabschnitte -**Status:** offen +**Status:** erledigt **Im Tracker:** [#51](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/51) **Meilenstein:** v0.2.0 **Labels:** phase-2, backend, frontend -**Geschätzt:** 8 h **Tatsächlich:** – +**Geschätzt:** 8 h **Tatsächlich:** 1 h 35 min ## Ziel @@ -16,34 +16,54 @@ Ein Abschnitt soll sagen können: **nur für diese Abteilungen.** ## 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. 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 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. ## 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 überleben, ohne ins Leere zu zeigen. Dieselbe Entscheidung wie bei `Mitarbeiter.abteilung` -- [ ] Beim Start: die geltenden Abschnitte ermitteln und am Lauf festhalten -- [ ] Die Abarbeitungsansicht zeigt nur geltende Abschnitte; die Nummerierung (`01 ·`) zählt nur sie -- [ ] **Die Abschlussregel zählt nur geltende Pflichtschritte.** Ein ausgefilterter Pflichtschritt +- [x] Beim Start: die geltenden Abschnitte ermitteln und am Lauf festhalten +- [x] Die Abarbeitungsansicht zeigt nur geltende Abschnitte; die Nummerierung (`01 ·`) zählt nur sie +- [x] **Die Abschlussregel zählt nur geltende Pflichtschritte.** Ein ausgefilterter Pflichtschritt darf einen Lauf nicht blockieren — sonst ist der Abschluss unerreichbar -- [ ] Druckansicht und Archiv zeigen dieselbe Auswahl wie die Abarbeitung -- [ ] Editor: je Abschnitt eine Mehrfachauswahl über die gepflegten Abteilungen, mit Freitext für +- [x] Druckansicht und Archiv zeigen dieselbe Auswahl wie die Abarbeitung +- [x] Editor: je Abschnitt eine Mehrfachauswahl über die gepflegten Abteilungen, mit Freitext für eine, die es noch nicht gibt -- [ ] Der Start-Dialog sagt vorher, wie viele Abschnitte die gewählte Abteilung hinzunimmt -- [ ] Anleitung Kapitel 04 und 02 +- [x] Der Start-Dialog sagt vorher, wie viele Abschnitte die gewählte Abteilung hinzunimmt +- [x] Anleitung Kapitel 04 und 02 ## Akzeptanzkriterien -- [ ] 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 -- [ ] Test: die Abteilung eines laufenden Laufs ändern ändert **nicht** seine Abschnitte -- [ ] Test: ein ausgefilterter Pflichtschritt blockiert den Abschluss nicht -- [ ] Test: die Druckansicht zeigt genau die Abschnitte der Abarbeitung — nicht mehr und nicht weniger -- [ ] Am Handy kein horizontales Scrollen +- [x] Test: ein Lauf für „Buchhaltung“ enthält den Buchhaltungs-Abschnitt, einer für „Vertrieb“ nicht +- [x] Test: ein Lauf ohne Abteilung enthält nur die uneingeschränkten Abschnitte +- [x] Test: die Abteilung eines laufenden Laufs ändern ändert **nicht** seine Abschnitte +- [x] Test: ein ausgefilterter Pflichtschritt blockiert den Abschluss nicht +- [x] Test: die Druckansicht zeigt genau die Abschnitte der Abarbeitung — nicht mehr und nicht weniger +- [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). diff --git a/docs/issues/README.md b/docs/issues/README.md index 7c7d859..5b66f0f 100644 --- a/docs/issues/README.md +++ b/docs/issues/README.md @@ -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 | – | | #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 | -| #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 | – | | #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 | – | | #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** | diff --git a/prisma/migrations/20261004210000_abteilungsabschnitte/migration.sql b/prisma/migrations/20261004210000_abteilungsabschnitte/migration.sql new file mode 100644 index 0000000..d73a552 --- /dev/null +++ b/prisma/migrations/20261004210000_abteilungsabschnitte/migration.sql @@ -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[]; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5d20196..c96f83e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -464,10 +464,16 @@ model VorlagenVariable { } model VorlagenAbschnitt { - id String @id @default(cuid()) - versionId String - titel String - position Int @default(0) + id String @id @default(cuid()) + versionId String + titel String + 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). /// /// 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. /// Welche der beiden Lesarten gilt, sagt der Status der Version; ein zweites /// Feld dafuer waere eine zweite Wahrheit. - bausteinVersionId String? + bausteinVersionId String? version VorlagenVersion @relation(fields: [versionId], references: [id], onDelete: Cascade) bausteinVersion BausteinVersion? @relation(fields: [bausteinVersionId], references: [id], onDelete: Restrict) @@ -591,6 +597,14 @@ model Lauf { ticket String? /// Werte der VorlagenVariablen, beim Start erfasst. 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) zugewiesenAnId String? faelligAm DateTime? diff --git a/src/app/(app)/vorlagen/version/[versionId]/page.tsx b/src/app/(app)/vorlagen/version/[versionId]/page.tsx index 21dfc94..dd993bf 100644 --- a/src/app/(app)/vorlagen/version/[versionId]/page.tsx +++ b/src/app/(app)/vorlagen/version/[versionId]/page.tsx @@ -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 ( ({ 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; diff --git a/src/components/laeufe/lauf-starten.tsx b/src/components/laeufe/lauf-starten.tsx index e562368..73905a5 100644 --- a/src/components/laeufe/lauf-starten.tsx +++ b/src/components/laeufe/lauf-starten.tsx @@ -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>({}); 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({ + ))} + + ) : null} + + {fremde.length > 0 ? ( +
    + {fremde.map((name) => ( +
  • + {name} + {lesend ? null : ( + + )} +
  • + ))} +
+ ) : null} + + {lesend ? null : ( +
+ 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" + /> + +
+ )} + + ); +} diff --git a/src/components/vorlagen/editor-abschnitt.tsx b/src/components/vorlagen/editor-abschnitt.tsx index 2006175..14be520 100644 --- a/src/components/vorlagen/editor-abschnitt.tsx +++ b/src/components/vorlagen/editor-abschnitt.tsx @@ -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 ) : null} + {abschnitt.nurFuerAbteilungen.length > 0 ? ( + + nur {abschnitt.nurFuerAbteilungen.join(", ")} + + ) : 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). + */} + aendern({ nurFuerAbteilungen })} + /> + {baustein ? null : anzahlSchritte === 0 ? (

Noch kein Schritt in diesem Abschnitt.

) : ( diff --git a/src/components/vorlagen/editor.tsx b/src/components/vorlagen/editor.tsx index 3c88c34..bc5f9cd 100644 --- a/src/components/vorlagen/editor.tsx +++ b/src/components/vorlagen/editor.tsx @@ -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(() => @@ -336,6 +339,7 @@ export function Editor({ abschnitt={abschnitt} index={index} anzahl={aufbau.abschnitte.length} + abteilungen={abteilungen} lesend={lesend} zuletztAngelegt={zuletztAngelegt} anfasser={anfasser} diff --git a/src/lib/editor-aufbau.ts b/src/lib/editor-aufbau.ts index 2002966..1bdd2a3 100644 --- a/src/lib/editor-aufbau.ts +++ b/src/lib/editor-aufbau.ts @@ -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 diff --git a/src/lib/geltende-abschnitte.ts b/src/lib/geltende-abschnitte.ts new file mode 100644 index 0000000..fd2d9c4 --- /dev/null +++ b/src/lib/geltende-abschnitte.ts @@ -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( + 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)); +} diff --git a/src/lib/vorlagen-format.ts b/src/lib/vorlagen-format.ts index 7c1c50f..47d3e3f 100644 --- a/src/lib/vorlagen-format.ts +++ b/src/lib/vorlagen-format.ts @@ -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( diff --git a/src/server/queries/druck.ts b/src/server/queries/druck.ts index 01d3a78..60c7e3a 100644 --- a/src/server/queries/druck.ts +++ b/src/server/queries/druck.ts @@ -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; diff --git a/src/server/queries/laeufe.ts b/src/server/queries/laeufe.ts index d8dcc44..9ecaf20 100644 --- a/src/server/queries/laeufe.ts +++ b/src/server/queries/laeufe.ts @@ -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 ({ + titel: abschnitt.titel, + abteilungen: abschnitt.nurFuerAbteilungen, + })), }); } return startbare; diff --git a/src/server/queries/vorlagen.ts b/src/server/queries/vorlagen.ts index e7b00e4..526d269 100644 --- a/src/server/queries/vorlagen.ts +++ b/src/server/queries/vorlagen.ts @@ -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 }), diff --git a/src/server/services/laeufe.ts b/src/server/services/laeufe.ts index 828192c..f3332b2 100644 --- a/src/server/services/laeufe.ts +++ b/src/server/services/laeufe.ts @@ -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; diff --git a/src/server/services/vorlagen-austausch.ts b/src/server/services/vorlagen-austausch.ts index 6eb9456..60ea6d8 100644 --- a/src/server/services/vorlagen-austausch.ts +++ b/src/server/services/vorlagen-austausch.ts @@ -70,6 +70,9 @@ export async function vorlageExportieren(versionId: string): Promise })), 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), })), }, diff --git a/src/server/services/vorlagen.ts b/src/server/services/vorlagen.ts index 207b293..52aa74e 100644 --- a/src/server/services/vorlagen.ts +++ b/src/server/services/vorlagen.ts @@ -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) { diff --git a/tests/abteilungsabschnitte.test.ts b/tests/abteilungsabschnitte.test.ts new file mode 100644 index 0000000..df7cfad --- /dev/null +++ b/tests/abteilungsabschnitte.test.ts @@ -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", + ]); + }); +}); diff --git a/tests/editor-aufbau.test.ts b/tests/editor-aufbau.test.ts index 4f0f8d6..853ddd2 100644 --- a/tests/editor-aufbau.test.ts +++ b/tests/editor-aufbau.test.ts @@ -151,6 +151,7 @@ describe("Zustand und Struktur", () => { { kennung: "a", titel: "Vorbereitung", + nurFuerAbteilungen: [], schritte: [ { kennung: "s", -- 2.54.0