feat(vorlagen): Abschnitte nur fuer bestimmte Abteilungen #58
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||

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