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
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||

|

|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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[];
|
||||||
@@ -468,6 +468,12 @@ model VorlagenAbschnitt {
|
|||||||
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
|
||||||
@@ -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", {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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),
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -151,6 +151,7 @@ describe("Zustand und Struktur", () => {
|
|||||||
{
|
{
|
||||||
kennung: "a",
|
kennung: "a",
|
||||||
titel: "Vorbereitung",
|
titel: "Vorbereitung",
|
||||||
|
nurFuerAbteilungen: [],
|
||||||
schritte: [
|
schritte: [
|
||||||
{
|
{
|
||||||
kennung: "s",
|
kennung: "s",
|
||||||
|
|||||||
Reference in New Issue
Block a user