feat(laeufe): Service, Abarbeitung, Kommentare und Archivschutz #27

Merged
florian.wilde merged 1 commits from feat/13-laeufe-abarbeitung into main 2026-09-25 16:56:34 +00:00
44 changed files with 7440 additions and 61 deletions
Showing only changes of commit ed6e6b81eb - Show all commits
+43
View File
@@ -94,6 +94,28 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
(#012)
- „Alle Befehle als Skript kopieren“ je Abschnitt: die Befehle in Reihenfolge, je mit einer
Kommentarzeile darüber und dem Zeilenumbruch der jeweiligen Shell (#012)
- Läufe: anlegen — auch als `offen` ohne Start, das ist die Zuweisung —, abarbeiten, abschließen
und archivieren. Ein Lauf wird erst `in Arbeit`, wenn ihn jemand wirklich öffnet (#013)
- Abarbeitungsansicht: nummerierte Abschnitte, Fortschrittsbalken, je Schritt Zustand, Wert,
Notiz, Kommentare und „wer wann“; jeder Schritt speichert einzeln, damit ein verlorener Klick
nicht zwei Stunden Arbeit kostet (#013)
- Die Ansicht frischt sich alle zehn Sekunden auf und pausiert, solange der Tab im Hintergrund
liegt — zwei Techniker an einem Onboarding sehen die Arbeit des anderen (#013)
- Umschalter „Kompakt / Ausführlich“: beim ersten Mal sind die Hilfetexte der eigentliche Wert,
beim dritten Mal stehen sie im Weg (#013)
- Befehlsschritte zeigen den fertigen Befehl mit eingesetzten Werten und einem Kopier-Knopf; ein
unaufgelöster Platzhalter wird angezeigt, statt stillschweigend mitkopiert zu werden (#013)
- „Abschnitt trifft nicht zu“ setzt alle Schritte eines Abschnitts mit einer Begründung auf
`nicht zutreffend` — für die Konto-Abschnitte eines Onboardings ohne eigenes AD (#013)
- Kommentare am Lauf und am einzelnen Schritt (#013)
- Ein Lauf mit Freitextnamen lässt sich nachträglich mit einem Konto aus dem Verzeichnis
verknüpfen: beim Onboarding gibt es die Person dort noch nicht (#013)
- Start-Dialog mit Vorbelegung aus der Adresse, Laufliste mit Suche nach Titel, Ticket, Hostname
und Person sowie Filter nach Status und eigener Zuweisung (#013)
- Der Start-Dialog schlägt `upn`, `sam` und `mail` nach den Namensregeln vor, sobald die Vorlage
solche Variablen hat; ausgefüllte Felder bleiben stehen (#013)
- Abschnitte klappen zu, sobald in ihnen nichts mehr zu tun ist — wer einen wieder aufklappt,
behält ihn offen, auch über das Speichern hinweg (#013)
### Sicherheit
@@ -159,3 +181,24 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
`[Passwort – nicht gespeichert]` (#012)
- Ein eingesetzter Wert wird nicht doppelt maskiert: React maskiert Textknoten selbst, und ein Test
am gerenderten HTML hält fest, dass ein `<script>` im Namen wirklich als Text ankommt (#012)
- Der Archiv-Schreibschutz sitzt an genau einer Stelle und wird vor jeder Änderung an Kopfdaten,
Schrittergebnis und Kommentar gerufen; ein abgeschlossener Lauf bleibt änderbar, ein archivierter
nicht, und zurückholen darf nur ein Administrator (#013)
- Ein Abschluss nimmt sich selbst zurück, sobald eine spätere Änderung einen Pflichtschritt wieder
offen lässt — „abgeschlossen“ soll nicht lügen (#013)
- `nicht zutreffend` und `fehlgeschlagen` verlangen eine Begründung; das Feld erscheint, bevor
gespeichert wird, statt den Benutzer in eine Absage laufen zu lassen (#013)
- Der Zustands-Endpunkt für die Aktualisierung prüft das Leserecht selbst, liefert einen fremden
Lauf als „nicht gefunden“ und **keine Befehle** — sonst entstünde bei jedem Abruf ein neues
generiertes Passwort (#013)
- In einem Lauf steht **ein** generiertes Passwort, nicht je Vorkommen ein anderes: der Befehl, der
das Konto anlegt, und die Zeile, die das Passwort mitteilt, meinen dasselbe. Vorher hätte das
Konto A bekommen und der Benutzer B erfahren — und A wäre unwiederbringlich gewesen (#013)
- Die Aktualisierung lädt die Seite nicht von selbst neu, solange ein generiertes Passwort darauf
steht; sie fragt (#013)
- Eine Anfrage unter `/api/` ohne Anmeldung bekommt `401` statt einer Weiterleitung auf die
Anmeldeseite — ein `fetch` folgte ihr und hielt die HTML-Antwort für einen Erfolg (#013)
- Zuweisen lässt sich ein Lauf nur an jemanden, der ihn auch öffnen könnte; die Auswahl zeigt keine
Bearbeiter fremder Kunden mehr (#013)
- „Nicht zutreffend“ und „fehlgeschlagen“ lassen sich mit einem Klick zurücknehmen. Vorher sah es
danach aus, als wäre nichts gewählt, während in der Datenbank weiter der alte Zustand stand (#013)
+24
View File
@@ -68,6 +68,15 @@ Zwei Regeln tragen die Nachvollziehbarkeit:
Beides ist durch Tests abgedeckt, nicht durch Disziplin.
**Abgeschlossen ist nicht archiviert**, und der Unterschied ist Absicht. „Abgeschlossen“ ist ein
Arbeitszustand: fertig für jetzt. Eine nachgetragene Notiz, ein Kommentar mit dem, was beim
Nachfassen herauskam, eine korrigierte Seriennummer — das passiert danach, und zwar oft. Würde
schon der Abschluss sperren, bräuchte jede Kleinigkeit einen Administrator, und die Technikerin
würde Läufe schlicht nicht mehr abschließen; das Archiv wäre danach nicht genauer, sondern leerer.
Unveränderlich wird ein Lauf durch das **Archivieren**, eine ausdrückliche zweite Handlung. Damit
„abgeschlossen“ trotzdem nicht lügt, nimmt der Abschluss sich selbst zurück, sobald eine Änderung
einen Pflichtschritt wieder offen lässt.
Der Aufbau einer Version wird **als Ganzes** gespeichert, nicht Schritt für Schritt: der Editor
hält den Baum im Speicher und sortiert per Drag & Drop, ein Teil-Update wäre dort ständig mit dem
Sortieren im Streit. Dasselbe Zod-Schema (`src/lib/vorlagen-format.ts`) beschreibt den Entwurf im
@@ -176,6 +185,21 @@ Das **generierte Passwort** entsteht erst beim Ersetzen und wird nirgends zurüc
keinen Weg, es außerhalb des fertigen Texts zu bekommen. Gäbe es ein Feld dafür, wäre die
Versuchung groß, es „nur fürs Protokoll“ mitzuschreiben.
## 5d. Warum die Aktualisierung keine Befehle mitliefert
Die Abarbeitungsansicht fragt alle zehn Sekunden `GET /api/laeufe/[id]/zustand` — zwei Techniker
teilen sich ein Onboarding und sollen die Arbeit des anderen sehen, ohne die Seite neu zu laden.
Diese Antwort trägt **nur** Status und Schrittzustände, ausdrücklich keine fertigen Befehle.
Der Grund ist `{{generiertes_passwort}}`: es entsteht bei jedem Auflösen neu. Lieferte das Polling
Befehle mit, stünde alle zehn Sekunden ein anderes Passwort auf dem Bildschirm als das, das der
Techniker gerade in die Konsole geklebt hat. Die Befehle entstehen deshalb genau einmal je
Seitenaufbau, und der Client lädt die Seite erst neu, wenn sich am Zustand wirklich etwas geändert
hat. Bei verstecktem Tab pausiert die Abfrage.
Der Route Handler prüft sein Recht selbst und gibt einen fremden Lauf als „nicht gefunden“ zurück.
Die Hülle unter `src/app/(app)/layout.tsx` deckt `src/app/api/` nicht ab.
## 6. Betrieb: zwei Wege, eine Regel
Die Datenbank ist auf beiden Wegen von außen nicht erreichbar:
+63 -2
View File
@@ -15,11 +15,12 @@ als Unterpunkte mit Aufgabe und Dauer.
| #010 | M5 – Vorlagen-Editor | 2 h 10 min |
| #011 | M6 – Mitarbeiter, Abteilungen, Geräte | 2 h 15 min |
| #012 | M7 – Platzhalter, Namensregeln, Escaping | 2 h 30 min |
| | **Summe** | **13 h 45 min** |
| #013 | M8 – Läufe: Service und Abarbeitung | 3 h 05 min |
| | **Summe** | **16 h 50 min** |
| Meilenstein | Tatsächlich | Status |
|---|---|---|
| v0.1.0 | 13 h 45 min | in Arbeit |
| v0.1.0 | 16 h 50 min | in Arbeit |
---
@@ -505,3 +506,63 @@ als Unterpunkte mit Aufgabe und Dauer.
- #013 Läufe: Service und Abarbeitung — Lauf anlegen, abarbeiten, abschließen, archivieren, mit
der Abarbeitungsansicht am Handy.
---
## 2026-09-25 · Sitzung 9 · 15:40–18:45
**Issue:** #013 (M8)
**Version:** 0.1.0
### Erledigt
- `src/server/services/{laeufe,schrittergebnisse,kommentare}.ts` (Sub-Agent): anlegen, öffnen,
abschließen, archivieren, zurückholen, Schrittergebnisse, Abschnitts-Aktion, Kommentare
- `src/server/queries/laeufe.ts`, die Abarbeitungsansicht, der Start-Dialog, die Laufliste und der
Zustands-Endpunkt für die Aktualisierung alle zehn Sekunden
- Namensvorschläge aus den Regeln im Start-Dialog und „Mit AD-Konto verknüpfen“ am Lauf
- Unabhängiger Review: **zwei Blocker**, zwölf Befunde, acht Anmerkungen — alle abgearbeitet
- **879 Tests** (vorher 810) und **37 Ende-zu-Ende-Tests** grün
### Entscheidungen
- **Ein Lauf, ein Passwort je Seitenaufbau.** Vorher entstand je *Vorkommen* ein neues: ein
`New-ADUser` mit `-AccountPassword` und einer Zeile, die das Passwort mitteilt, trug zwei
verschiedene. Das Konto bekäme A, der Benutzer erführe B, und weil nichts gespeichert wird, wäre
A für immer weg. „Bei jeder Anzeige neu“ heißt jetzt: je Aufbau der Seite neu, innerhalb der
Seite überall dasselbe.
- **Abgeschlossen ist nicht archiviert.** „Abgeschlossen“ ist ein Arbeitszustand; Nachträge
passieren danach, und zwar oft. Würde schon der Abschluss sperren, bräuchte jede Kleinigkeit
einen Administrator — und Läufe würden schlicht nicht mehr abgeschlossen.
- **Das Polling trägt keine Befehle, und es lädt auch nicht von selbst neu**, solange ein Passwort
auf der Seite steht. Die erste Hälfte der Zusage war leicht, die zweite fiel erst beim
Nachdenken über `router.refresh()` auf: ein Neuaufbau tauscht das Passwort genauso aus.
- **`/api/` bekommt ohne Anmeldung 401, keine Weiterleitung.** Ein `fetch` folgt einer
Weiterleitung von sich aus und hält die Anmeldeseite für eine gültige Antwort; die
Aktualisierung hätte stundenlang weitergefragt.
- **Abschnitte klappen zu, wenn sie fertig sind — der Zustand liegt im Browser.** Aus den Daten
gerechnet klappte ein von Hand geöffneter Abschnitt beim nächsten Speichern wieder zu.
### Probleme
- **React baut das Formular erst nach dem erneuten Rendern zusammen.** Mein erster Versuch, den
Zustand am abschickenden Knopf mitzugeben, setzte im selben Klick die Vorauswahl um — und damit
den Wert des Knopfes. Angekommen ist wahlweise „offen“ oder gar kein Feld. Gefunden nicht durch
Lesen, sondern durch Nachstellen im gebauten Stand mit einer Ausgabe der `FormData`.
- **Der Review hat einen Blocker gefunden, den mein eigener Test festgeschrieben hatte:** „zwei
Vorkommen ergeben zwei Passwörter“ stand als Erwartung samt Begründung im Test. Die Begründung
war falsch, der Test hat sie zementiert.
- **Ein `h2` in einem `button` ist keine Überschrift mehr.** In einem Knopf sind alle Nachfahren
nur noch Beschriftung; der Abschnittskopf verlor damit seine Gliederung — aufgefallen, weil ein
Playwright-Test die Überschrift nicht mehr fand.
- **Ein `details` und React geraten vor der Hydrierung aneinander:** der Browser klappt auf, React
klappt gleich darauf zu. Jetzt ein Knopf mit `aria-expanded`, der vor der Hydrierung schlicht
nichts tut — wie jeder andere Knopf auf dieser Seite.
- **Fünfter Meilenstein in Folge mit Kommentaren, die mehr versprechen als der Code hält:** der
Titelvorschlag behauptete, der Start-Dialog zeige ihn; `startAuswahl` behauptete, der Dialog
nenne seine Grenze von 500 Einträgen. Beides stimmte nicht.
### Nächster Schritt
- #014 QR-Tags, Geräteseite, Etiketten und Scanner — der Einstieg, für den die ganze Anwendung
gebaut ist: Code scannen, Geräteseite öffnen, loslegen.
+70 -33
View File
@@ -1,9 +1,9 @@
# #013 M8 – Läufe: Service und Abarbeitung
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.1.0
**Labels:** phase-1, backend, frontend
**Geschätzt:** 14 h **Tatsächlich:** –
**Geschätzt:** 14 h **Tatsächlich:** 3 h 05 min
## Ziel
@@ -11,37 +11,74 @@ Das Herz der Anwendung: ein Lauf wird vorbereitet, abgearbeitet, abgeschlossen u
## Aufgaben
- [ ] `laufAnlegenService`: aus veröffentlichter Version, Kunde per `kundeErmitteln()`, optional Mitarbeiter (Verzeichnis oder Freitext `mitarbeiterName`), optional Gerät, Ticket, Variablen, Zuweisung, Fälligkeit
- [ ] Ein Lauf kann als `OFFEN` angelegt werden, ohne ihn zu starten — das ist der Zuweisungs-Mechanismus, keine eigene Tabelle
- [ ] `gestartetAm` und `IN_ARBEIT` erst beim ersten Öffnen zur Bearbeitung
- [ ] Titelvorschlag `<Vorlage> – <Hostname oder Mitarbeiter> – <Datum>`
- [ ] `schrittergebnisSetzenService`: einzeln je Schritt (kein Gesamtformular), `zustand`, `wert`, `notiz`, `aktualisiertVonId`, `Protokoll`-Eintrag
- [ ] `NICHT_ZUTREFFEND` und `FEHLGESCHLAGEN` verlangen eine Notiz, sonst `LaufFehler`
- [ ] `abschnittNichtZutreffendService`: setzt alle Schritte eines Abschnitts mit einer Notiz auf `NICHT_ZUTREFFEND` (für die beiden Konto-Abschnitte im Onboarding)
- [ ] `laufAbschliessenService`: nur wenn alle Pflichtschritte `ERLEDIGT` oder `NICHT_ZUTREFFEND` sind; setzt `abgeschlossenAm`, `abgeschlossenVonId`
- [ ] `laufArchivierenService` und `laufZurueckholenService` (nur `ADMIN`), beide mit `Protokoll`-Eintrag
- [ ] `laufMussVeraenderbarSein(lauf)` zentral, aufgerufen vor jeder Änderung an Kopfdaten, `Schrittergebnis` und `Kommentar`
- [ ] Kommentare je Lauf und je Schrittergebnis
- [ ] „Mit AD-Konto verknüpfen“: ein Lauf mit `mitarbeiterName` wird nachträglich über UPN oder Name mit einem `Mitarbeiter` verbunden
- [ ] Start-Dialog: Vorlage → Mitarbeiter → Gerät (wählen, neu anlegen oder scannen) → Ticket, Variablen, Zuweisung, Fälligkeit; Vorbelegung je Aufrufort, Besitzer des Geräts als Mitarbeiter vorgeschlagen; Vorschläge aus den Namensregeln für `upn`, `sam`, `mail`
- [ ] Abarbeitungsansicht: einklappbare Abschnitte mit `01 ·`-Nummerierung, Fortschrittsbalken in Lime, je Schritt Zustand, Wert, Notiz, Kommentare und „wer wann“
- [ ] Erledigte Schritte durchgestrichen in Nebentext-Farbe, Checkbox erledigt als Lime-Kachel mit dunklem Haken, Pflichtschritte mit rotem Stern
- [ ] `BEFEHL`-Schritte als Codeblock auf `#0B0D10` mit Lime-Schrift, Kopier-Knopf, Warnung wenn ein Platzhalter unaufgelöst ist; je Abschnitt „Alle Befehle als Skript kopieren“
- [ ] `ZAHL` außerhalb von Min/Max wird markiert, aber nicht blockiert
- [ ] Route Handler `GET /api/laeufe/[id]/zustand`, Client pollt alle 10 s und pausiert bei verstecktem Tab
- [ ] Umschalter „Kompakt / Ausführlich“, ausführlich klappt alle Hilfetexte auf
- [ ] Abschließen und Archivieren als getrennte Aktionen
- [ ] Screenshots Start-Dialog, Lauf Desktop, Lauf Handy, Befehlsschritt in `docs/screenshots/m8/`
- [x] `laufAnlegenService`: aus veröffentlichter Version, Kunde per `kundeErmitteln()`, optional Mitarbeiter (Verzeichnis oder Freitext `mitarbeiterName`), optional Gerät, Ticket, Variablen, Zuweisung, Fälligkeit
- [x] Ein Lauf kann als `OFFEN` angelegt werden, ohne ihn zu starten — das ist der Zuweisungs-Mechanismus, keine eigene Tabelle
- [x] `gestartetAm` und `IN_ARBEIT` erst beim ersten Öffnen zur Bearbeitung
- [x] Titelvorschlag `<Vorlage> – <Hostname oder Mitarbeiter> – <Datum>`
- [x] `schrittergebnisSetzenService`: einzeln je Schritt (kein Gesamtformular), `zustand`, `wert`, `notiz`, `aktualisiertVonId`, `Protokoll`-Eintrag
- [x] `NICHT_ZUTREFFEND` und `FEHLGESCHLAGEN` verlangen eine Notiz, sonst `LaufFehler`
- [x] `abschnittNichtZutreffendService`: setzt alle Schritte eines Abschnitts mit einer Notiz auf `NICHT_ZUTREFFEND` (für die beiden Konto-Abschnitte im Onboarding)
- [x] `laufAbschliessenService`: nur wenn alle Pflichtschritte `ERLEDIGT` oder `NICHT_ZUTREFFEND` sind; setzt `abgeschlossenAm`, `abgeschlossenVonId`
- [x] `laufArchivierenService` und `laufZurueckholenService` (nur `ADMIN`), beide mit `Protokoll`-Eintrag
- [x] `laufMussVeraenderbarSein(lauf)` zentral, aufgerufen vor jeder Änderung an Kopfdaten, `Schrittergebnis` und `Kommentar`
- [x] Kommentare je Lauf und je Schrittergebnis
- [x] „Mit AD-Konto verknüpfen“: ein Lauf mit `mitarbeiterName` wird nachträglich über UPN oder Name mit einem `Mitarbeiter` verbunden
- [x] Start-Dialog: Vorlage → Mitarbeiter → Gerät (wählen, neu anlegen oder scannen) → Ticket, Variablen, Zuweisung, Fälligkeit; Vorbelegung je Aufrufort, Besitzer des Geräts als Mitarbeiter vorgeschlagen; Vorschläge aus den Namensregeln für `upn`, `sam`, `mail` — **bis auf „neu anlegen oder scannen“**, siehe unten
- [x] Abarbeitungsansicht: einklappbare Abschnitte mit `01 ·`-Nummerierung, Fortschrittsbalken in Lime, je Schritt Zustand, Wert, Notiz, Kommentare und „wer wann“
- [x] Erledigte Schritte durchgestrichen in Nebentext-Farbe, Checkbox erledigt als Lime-Kachel mit dunklem Haken, Pflichtschritte mit rotem Stern
- [x] `BEFEHL`-Schritte als Codeblock auf `#0B0D10` mit Lime-Schrift, Kopier-Knopf, Warnung wenn ein Platzhalter unaufgelöst ist; je Abschnitt „Alle Befehle als Skript kopieren“
- [x] `ZAHL` außerhalb von Min/Max wird markiert, aber nicht blockiert
- [x] Route Handler `GET /api/laeufe/[id]/zustand`, Client pollt alle 10 s und pausiert bei verstecktem Tab
- [x] Umschalter „Kompakt / Ausführlich“, ausführlich klappt alle Hilfetexte auf
- [x] Abschließen und Archivieren als getrennte Aktionen
- [x] Screenshots Start-Dialog, Lauf Desktop, Lauf Handy, Befehlsschritt in `docs/screenshots/m8/`
## Akzeptanzkriterien
- [ ] Test: ein Lauf ohne Mitarbeiter und ohne Gerät lässt sich anlegen, abschließen und archivieren
- [ ] Test: Abschließen scheitert, solange ein Pflichtschritt `OFFEN` ist
- [ ] Test: `NICHT_ZUTREFFEND` ohne Notiz wird abgelehnt, mit Notiz akzeptiert
- [ ] Test: an einem archivierten Lauf schlagen Änderung eines Schrittergebnisses, eines Kommentars und der Kopfdaten fehl
- [ ] Test: nur `ADMIN` holt einen archivierten Lauf auf `ABGESCHLOSSEN` zurück, mit `Protokoll`-Eintrag
- [ ] Test: `gestartetAm` bleibt leer, solange der Lauf nur `OFFEN` ist
- [ ] Test: jede Änderung an einem Schrittergebnis, jeder Statuswechsel und jedes Veröffentlichen steht im `Protokoll`
- [ ] Test: die Abschnitts-Aktion setzt alle Schritte des Abschnitts mit derselben Notiz
- [ ] Zwei Browser auf demselben Lauf sehen die Änderung des anderen nach höchstens 10 s
- [ ] Am Handy bei 390 px kein horizontales Scrollen
- [x] Test: ein Lauf ohne Mitarbeiter und ohne Gerät lässt sich anlegen, abschließen und archivieren
- [x] Test: Abschließen scheitert, solange ein Pflichtschritt `OFFEN` ist
- [x] Test: `NICHT_ZUTREFFEND` ohne Notiz wird abgelehnt, mit Notiz akzeptiert
- [x] Test: an einem archivierten Lauf schlagen Änderung eines Schrittergebnisses, eines Kommentars und der Kopfdaten fehl
- [x] Test: nur `ADMIN` holt einen archivierten Lauf auf `ABGESCHLOSSEN` zurück, mit `Protokoll`-Eintrag
- [x] Test: `gestartetAm` bleibt leer, solange der Lauf nur `OFFEN` ist
- [x] Test: jede Änderung an einem Schrittergebnis, jeder Statuswechsel und jedes Veröffentlichen steht im `Protokoll`
- [x] Test: die Abschnitts-Aktion setzt alle Schritte des Abschnitts mit derselben Notiz
- [x] Zwei Browser auf demselben Lauf sehen die Änderung des anderen nach höchstens 10 s
- [x] Am Handy bei 390 px kein horizontales Scrollen
## Offen, verschoben nach #014
Ein Gerät **direkt im Start-Dialog anlegen oder scannen**. Beides hängt am QR-Einstieg, der dort
gebaut wird; der Dialog nimmt `?geraet=` schon heute entgegen, die Geräteseite füllt es dann.
## Ergebnis
Erledigt am 2026-09-25. 879 Tests (vorher 810) und 37 Ende-zu-Ende-Tests grün, Screenshots in
`docs/screenshots/m8/`.
Dazugekommen, weil sie sonst Zusagen ohne Deckung gewesen wären:
- Die Aktualisierung alle zehn Sekunden **lädt die Seite nicht von selbst neu**, wenn ein
generiertes Passwort darauf steht. Die Antwort des Pollings trägt zwar keine Befehle — aber ein
Neuaufbau der Seite hätte das Passwort genauso ausgetauscht. Jetzt fragt sie.
- Ein Abschnitt, in dem nichts mehr zu tun ist, klappt zu; der Zustand liegt im Browser.
- Die Oberfläche zu „Mit AD-Konto verknüpfen“ — den Dienst gab es, aber keinen Weg dorthin.
### Der unabhängige Review
Zwei Blocker, zwölf Befunde, acht Anmerkungen. Alle abgearbeitet, die wichtigsten:
- **Ein Befehl konnte zwei verschiedene Passwörter tragen** — je Vorkommen entstand ein neues. Das
Konto hätte A bekommen und der Benutzer B erfahren. Jetzt ein Passwort je Seitenaufbau, für alle
Befehle des Laufs. Ein eigener Test meines Vorgängerstands hatte das falsche Verhalten sogar
festgeschrieben.
- **„Nicht zutreffend“ ließ sich nicht zurücknehmen**, und die Oberfläche behauptete dabei, nichts
sei gewählt, während in der Datenbank der alte Zustand stand.
- **Bearbeiter fremder Kunden standen im Start-Dialog zur Auswahl** — der Dienst lehnte die
Zuweisung danach ab.
- **Kommentare und Kopfdaten lösten keine Aktualisierung aus**; Akzeptanzkriterium 9 galt nur für
Schrittzustände.
- **Abschnitte wurden über den Array-Index gepaart**, obwohl `(versionId, position)` nicht eindeutig
ist: „Abschnitt trifft nicht zu“ hätte den falschen treffen können.
- **Der Route Handler war nicht geprüft**, nur die Abfrage darunter. Dabei fiel auf, dass der Proxy
eine API-Anfrage ohne Anmeldung auf die Anmeldeseite umlenkte statt mit `401` zu antworten.
+2 -2
View File
@@ -17,11 +17,11 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
| #010 | [M5 – Vorlagen-Editor](010-vorlagen-editor.md) | erledigt | v0.1.0 | 10 h | 2 h 10 min |
| #011 | [M6 – Mitarbeiter, Abteilungen, Geräte](011-mitarbeiter-abteilungen-geraete.md) | erledigt | v0.1.0 | 9 h | 2 h 15 min |
| #012 | [M7 – Platzhalter, Namensregeln, Escaping](012-platzhalter-namensregeln.md) | erledigt | v0.1.0 | 8 h | 2 h 30 min |
| #013 | [M8 – Läufe: Service und Abarbeitung](013-laeufe-abarbeitung.md) | offen | v0.1.0 | 14 h | – |
| #013 | [M8 – Läufe: Service und Abarbeitung](013-laeufe-abarbeitung.md) | erledigt | v0.1.0 | 14 h | 3 h 05 min |
| #014 | [M9 – QR-Tags, Geräteseite, Etiketten, Scanner](014-qr-tags-geraeteseite.md) | offen | v0.1.0 | 12 h | – |
| #015 | [M10 – Dashboard und Archiv](015-dashboard-archiv.md) | offen | v0.1.0 | 8 h | – |
| #016 | [M11 – Verzeichnis und LDAP-Abgleich](016-verzeichnis-ldap.md) | offen | v0.1.0 | 12 h | – |
| #017 | [M12 – Seed der sieben Vorlagen](017-seed-sieben-vorlagen.md) | offen | v0.1.0 | 8 h | – |
| #018 | [M13 – E2E-Tests, Anleitung, PDF](018-e2e-anleitung-pdf.md) | offen | v0.1.0 | 14 h | – |
| #019 | [M14 – Betriebswege und Release v0.1.0](019-betrieb-release.md) | offen | v0.1.0 | 10 h | – |
| | **Summe** | | | **136 h** | **13 h 45 min** |
| | **Summe** | | | **136 h** | **16 h 50 min** |
Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

+1
View File
@@ -11,6 +11,7 @@
"scripts": {
"beispiel:stammdaten": "tsx scripts/beispiel-stammdaten.ts",
"beispiel:vorlage": "tsx scripts/beispiel-vorlage.ts",
"beispiel:lauf": "tsx scripts/beispiel-lauf.ts",
"build": "next build",
"db:down": "docker compose -f docker-compose.dev.yml down",
"db:generate": "prisma generate",
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env tsx
/**
* Beispiel-Laeufe fuer die Screenshots (#013).
*
* Eine Abarbeitungsansicht ohne Lauf zeigt nichts, und die echten Beispieldaten
* kommen erst mit #017. Bis dahin legt dieses Skript zwei Laeufe an: einen
* begonnenen mit Fortschritt, Notiz und Kommentar, und einen vorbereiteten im
* Zustand `OFFEN` — der Fall, den der Auftrag ausdruecklich verlangt (ein
* Kollege richtet ein, der Techniker scannt und arbeitet ab).
*
* Es geht durch die echten Dienste. Waeren die Regeln anders als hier
* angenommen, schlaegt es fehl, statt eine Lage herzustellen, die es in der
* Anwendung nie gibt.
*
* Idempotent. Mit #017 wird das Skript geloescht.
*
* pnpm beispiel:lauf
*/
import "dotenv/config";
import { SchrittZustand } from "../src/generated/prisma/enums.js";
import { prisma } from "../src/lib/db.js";
import { kommentarHinzufuegen } from "../src/server/services/kommentare.js";
import { laufAnlegen, laufZurBearbeitungOeffnen } from "../src/server/services/laeufe.js";
import { schrittergebnisSetzen } from "../src/server/services/schrittergebnisse.js";
const VORLAGE = "PC-Neuinstallation (Beispiel)";
const admin = await prisma.benutzer.findFirst({
where: { rolle: "ADMIN" },
orderBy: { erstelltAm: "asc" },
select: { id: true, rolle: true },
});
if (!admin) {
throw new Error("Es gibt keinen Administrator. Erst pnpm db:seed laufen lassen.");
}
const handelnder = { id: admin.id, rolle: admin.rolle };
const version = await prisma.vorlagenVersion.findFirst({
where: { status: "VEROEFFENTLICHT", vorlage: { name: VORLAGE } },
orderBy: { version: "desc" },
select: { id: true },
});
if (!version) {
throw new Error(`Es gibt keine veröffentlichte Version von „${VORLAGE}“. Erst pnpm beispiel:vorlage.`);
}
const geraet = await prisma.geraet.findFirst({ where: { hostname: "NB-001" }, select: { id: true } });
const person = await prisma.mitarbeiter.findFirst({ where: { sam: "mhuber" }, select: { id: true } });
if (!geraet || !person) {
throw new Error("NB-001 oder mhuber fehlen. Erst pnpm beispiel:stammdaten laufen lassen.");
}
const abteilung = await prisma.abteilung.findFirst({
where: { name: "Buchhaltung" },
select: { id: true },
});
/** Die Schritte des ersten Abschnitts, in ihrer Reihenfolge. */
const abschnitt = await prisma.vorlagenAbschnitt.findFirstOrThrow({
where: { versionId: version.id },
orderBy: { position: "asc" },
select: { schritte: { orderBy: { position: "asc" }, select: { id: true, titel: true } } },
});
const TITEL_BEGONNEN = "PC-Neuinstallation · NB-001 · Maria Huber";
const TITEL_OFFEN = "PC-Neuinstallation · PC-001 · vorbereitet";
const schonDa = await prisma.lauf.findFirst({ where: { titel: TITEL_BEGONNEN }, select: { id: true } });
if (schonDa) {
console.log("Die Beispiel-Läufe gibt es schon – nichts zu tun.");
} else {
const begonnen = await laufAnlegen(
{
vorlagenVersionId: version.id,
titel: TITEL_BEGONNEN,
geraetId: geraet.id,
mitarbeiterId: person.id,
abteilungId: abteilung?.id ?? null,
ticket: "TIK-4711",
variablen: { os_version: "Windows 11 Pro 24H2", join_typ: "Active Directory", arbeitsspeicher: 16 },
zugewiesenAnId: admin.id,
},
handelnder,
);
await laufZurBearbeitungOeffnen(begonnen.id, handelnder);
// Zwei erledigte Schritte und einer, der nicht zutrifft: so zeigt der
// Fortschrittsbalken einen echten Stand und die Begruendung steht am Schritt.
const [erster, zweiter, dritter] = abschnitt.schritte;
for (const schritt of [erster, zweiter]) {
if (!schritt) continue;
await schrittergebnisSetzen(
{ laufId: begonnen.id, vorlagenSchrittId: schritt.id, zustand: SchrittZustand.ERLEDIGT },
handelnder,
);
}
if (dritter) {
await schrittergebnisSetzen(
{
laufId: begonnen.id,
vorlagenSchrittId: dritter.id,
zustand: SchrittZustand.NICHT_ZUTREFFEND,
notiz: "Gerät ist neu ausgepackt – es gibt noch keine Altdaten.",
},
handelnder,
);
}
await kommentarHinzufuegen(
{
laufId: begonnen.id,
rumpf: "Rückfrage an die Buchhaltung: Steuer-Software wird erst nächste Woche gebraucht.",
},
handelnder,
);
const pc001 = await prisma.geraet.findFirst({ where: { hostname: "PC-001" }, select: { id: true } });
const morgen = new Date(Date.now() + 24 * 60 * 60 * 1000);
await laufAnlegen(
{
vorlagenVersionId: version.id,
titel: TITEL_OFFEN,
geraetId: pc001?.id ?? null,
variablen: { os_version: "Windows 11 Pro 24H2", join_typ: "Active Directory" },
ticket: "TIK-4712",
zugewiesenAnId: admin.id,
faelligAm: morgen,
},
handelnder,
);
console.log("Zwei Beispiel-Läufe angelegt: einer in Arbeit, einer vorbereitet.");
}
await prisma.$disconnect();
+41 -1
View File
@@ -20,7 +20,7 @@
*/
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import { chromium, type Browser, type Cookie, type Page } from "@playwright/test";
import { chromium, type Browser, type Cookie, type Locator, type Page } from "@playwright/test";
const APP_URL = process.env["E2E_APP_URL"] ?? "http://127.0.0.1:3000";
@@ -42,6 +42,12 @@ type Seite = {
readonly nurModus?: "INTERN" | "DIENSTLEISTUNG";
/** Vor der Aufnahme ausfuehren, etwa ein Aufklappen. */
readonly vorbereiten?: (tab: Page) => Promise<void>;
/**
* Nur diesen Ausschnitt aufnehmen statt der ganzen Seite. Fuer Bilder, die
* ein einzelnes Element zeigen sollen — eine ganze Seite waere dasselbe Bild
* wie nebenan, nur groesser.
*/
readonly ausschnitt?: (tab: Page) => Locator;
};
/** Seiten, die abgebildet werden. Waechst mit jedem Meilenstein. */
@@ -124,6 +130,36 @@ const SEITEN: readonly Seite[] = [
},
},
{ name: "geraete-liste", pfad: "/geraete" },
{ name: "laeufe-liste", pfad: "/laeufe" },
{ name: "lauf-starten", pfad: "/laeufe/starten" },
{
name: "lauf-abarbeiten",
pfad: "/laeufe",
vorbereiten: async (tab) => {
await tab
.getByRole("link", { name: /NB-001/ })
.first()
.click();
await tab.waitForURL(/\/laeufe\/[^/]+$/);
},
},
{
// Der Befehlsschritt, aufgeklappt und in den Blick gescrollt: am Handy
// stuende er sonst weit unterhalb des Bildrands.
name: "lauf-befehlsschritt",
pfad: "/laeufe",
vorbereiten: async (tab) => {
await tab
.getByRole("link", { name: /NB-001/ })
.first()
.click();
await tab.waitForURL(/\/laeufe\/[^/]+$/);
const schritt = tab.getByRole("listitem").filter({ hasText: "Rechner umbenannt" });
await schritt.scrollIntoViewIfNeeded();
await tab.waitForTimeout(150);
},
ausschnitt: (tab) => tab.getByRole("listitem").filter({ hasText: "Rechner umbenannt" }),
},
{ name: "abteilungen", pfad: "/admin/abteilungen" },
];
@@ -226,7 +262,11 @@ try {
await tab.waitForLoadState("domcontentloaded");
const datei = join(ziel, `${seite.name}-${ansicht.name}.png`);
if (seite.ausschnitt) {
await seite.ausschnitt(tab).screenshot({ path: datei });
} else {
await tab.screenshot({ path: datei, fullPage: ansicht.name === "desktop" });
}
// Am Handy darf nie horizontal gescrollt werden – hier gleich mitgeprueft.
if (ansicht.name === "handy") {
+406
View File
@@ -0,0 +1,406 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { ArrowLeft, MessageSquare, Star } from "lucide-react";
import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums";
import { Markdown } from "@/components/markdown";
import { AbschnittHuelle } from "@/components/laeufe/abschnitt-huelle";
import { Befehlsblock } from "@/components/laeufe/befehlsblock";
import { LaufAktualisierung } from "@/components/laeufe/lauf-aktualisierung";
import {
AbschnittNichtZutreffend,
KommentarSchreiben,
LaufAbschliessen,
LaufArchivieren,
LaufBeginnen,
LaufMitAdKontoVerknuepfen,
LaufZurueckholen,
} from "@/components/laeufe/lauf-aktionen";
import { SchrittWert } from "@/components/laeufe/schritt-wert";
import { SchrittZustandWaehlen, ZustandKennzeichen } from "@/components/laeufe/schritt-zustand";
import { darf } from "@/server/auth/rollen";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import {
kommentareDesLaufs,
laufZumAbarbeiten,
personenZurVerknuepfung,
type AbarbeitungsAbschnitt,
type AbarbeitungsSchritt,
type KommentarZeile,
} from "@/server/queries/laeufe";
/**
* Die Abarbeitungsansicht (#013).
*
* Das ist die Seite, die ein Techniker stundenlang offen hat, oft am Handy, oft
* neben einem aufgeschraubten Rechner. Drei Dinge folgen daraus:
*
* 1. **Jeder Schritt speichert einzeln.** Ein Gesamtformular hiesse, dass ein
* verlorener Klick zwei Stunden Haken kostet.
* 2. **Die Seite aktualisiert sich alle zehn Sekunden** — zwei Techniker teilen
* sich ein Onboarding und sollen die Arbeit des anderen sehen.
* 3. **„Kompakt“ und „Ausführlich“** sind zwei Lesarten desselben Laufs: beim
* dritten Mal will niemand mehr die Hilfetexte lesen, beim ersten Mal sind
* sie der eigentliche Wert.
*/
const ZEITPUNKT = new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
function Fortschrittsbalken({ erledigt, gesamt }: { erledigt: number; gesamt: number }) {
const anteil = gesamt === 0 ? 0 : erledigt / gesamt;
return (
<div className="mt-4 flex items-center gap-3">
<div
className="h-2 min-w-0 flex-1 overflow-hidden rounded-pille bg-flaeche-hoch"
role="progressbar"
aria-valuemin={0}
aria-valuemax={gesamt}
aria-valuenow={erledigt}
aria-label="Fortschritt des Laufs"
>
<div
className="h-full bg-akzent transition-[width]"
style={{ width: `${Math.round(anteil * 100)}%` }}
/>
</div>
<span className="shrink-0 text-sm text-nebentext">
{erledigt} von {gesamt}
</span>
</div>
);
}
function Schritt({
schritt,
laufId,
lesend,
ausfuehrlich,
kommentare,
}: {
schritt: AbarbeitungsSchritt;
laufId: string;
lesend: boolean;
ausfuehrlich: boolean;
kommentare: readonly KommentarZeile[];
}) {
const vomTisch =
schritt.zustand === SchrittZustand.ERLEDIGT || schritt.zustand === SchrittZustand.NICHT_ZUTREFFEND;
const brauchtBegruendung =
schritt.zustand === SchrittZustand.NICHT_ZUTREFFEND || schritt.zustand === SchrittZustand.FEHLGESCHLAGEN;
return (
<li className="border-t border-linie py-4 first:border-t-0">
<div className="flex items-start gap-3">
<span className="mono mt-0.5 shrink-0 text-xs text-nebentext">
{String(schritt.nummer).padStart(2, "0")}
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className={vomTisch ? "text-nebentext line-through" : "font-medium"}>{schritt.titel}</span>
{schritt.pflicht ? (
<Star className="size-3 shrink-0 text-fehler" strokeWidth={3} aria-label="Pflichtschritt" />
) : null}
{/* Im Lesemodus traegt `SchrittZustandWaehlen` das Kennzeichen selbst. */}
{lesend ? null : <ZustandKennzeichen zustand={schritt.zustand} />}
</div>
{/*
* „Kompakt“ blendet **alle** Hilfetexte aus, nicht nur die der
* erledigten Schritte. Beim dritten Mal kennt der Techniker sie, und
* eine halbe Regel („nur bei erledigten“) haette ihm am Handy
* trotzdem die halbe Seite mit Text gefuellt (Reviewbefund zu #013).
*/}
{schritt.hilfetext && ausfuehrlich ? (
<div className="mt-2">
<Markdown text={schritt.hilfetext} />
</div>
) : null}
{schritt.dokuUrl && /^https?:\/\//iu.test(schritt.dokuUrl) ? (
<a
href={schritt.dokuUrl}
target="_blank"
rel="noreferrer"
className="mt-2 inline-flex min-h-11 items-center text-sm text-akzent underline underline-offset-2 md:min-h-10"
>
Anleitung öffnen
</a>
) : null}
{schritt.befehl ? (
<div className="mt-3">
<Befehlsblock
text={schritt.befehl.text}
unbekannt={schritt.befehl.unbekannt}
hinweis={schritt.befehl.hinweis}
fehler={schritt.befehl.fehler}
/>
</div>
) : null}
{schritt.typ !== SchrittTyp.HAKEN && schritt.typ !== SchrittTyp.BEFEHL ? (
<div className="mt-3">
<SchrittWert
laufId={laufId}
vorlagenSchrittId={schritt.schrittId}
typ={schritt.typ}
konfig={schritt.konfig}
wert={schritt.wert}
zustand={schritt.zustand}
lesend={lesend}
/>
</div>
) : null}
<div className="mt-3">
<SchrittZustandWaehlen
laufId={laufId}
vorlagenSchrittId={schritt.schrittId}
zustand={schritt.zustand}
notiz={schritt.notiz}
lesend={lesend}
/>
</div>
{/*
* Die Notiz als Text — aber nur, wenn sie nicht ohnehin im
* Begruendungsfeld steht. `NICHT_ZUTREFFEND` und `FEHLGESCHLAGEN`
* blenden dieses Feld gefuellt ein; ein Absatz darunter waere
* dieselbe Zeile ein zweites Mal.
*/}
{schritt.notiz && !lesend && !brauchtBegruendung ? (
<p className="mt-2 max-w-prose text-sm text-nebentext">{schritt.notiz}</p>
) : null}
{schritt.bearbeitetVon && schritt.bearbeitetAm ? (
<p className="mt-2 text-xs text-nebentext">
{schritt.bearbeitetVon}, {ZEITPUNKT.format(schritt.bearbeitetAm)}
</p>
) : null}
{kommentare.length > 0 ? (
<ul className="mt-3 flex flex-col gap-2">
{kommentare.map((kommentar) => (
<li key={kommentar.id} className="rounded-karte bg-flaeche-hoch p-3 text-sm">
<p className="text-text">{kommentar.rumpf}</p>
<p className="mt-1 text-xs text-nebentext">
{kommentar.benutzer}, {ZEITPUNKT.format(kommentar.erstelltAm)}
</p>
</li>
))}
</ul>
) : null}
{lesend || !schritt.ergebnisId ? null : (
<details className="mt-2">
<summary className="flex min-h-11 cursor-pointer list-none items-center gap-2 text-xs text-nebentext hover:text-text md:min-h-9">
<MessageSquare className="size-3.5" strokeWidth={1.75} aria-hidden="true" />
Kommentar zu diesem Schritt
</summary>
<div className="mt-2">
<KommentarSchreiben laufId={laufId} schrittergebnisId={schritt.ergebnisId} />
</div>
</details>
)}
</div>
</div>
</li>
);
}
function Abschnitt({
abschnitt,
laufId,
lesend,
ausfuehrlich,
kommentare,
}: {
abschnitt: AbarbeitungsAbschnitt;
laufId: string;
lesend: boolean;
ausfuehrlich: boolean;
kommentare: readonly KommentarZeile[];
}) {
const erledigt = abschnitt.schritte.filter(
(schritt) =>
schritt.zustand === SchrittZustand.ERLEDIGT || schritt.zustand === SchrittZustand.NICHT_ZUTREFFEND,
).length;
// Zu, sobald im Abschnitt nichts mehr zu tun ist – aber nur beim ersten Aufbau.
const alleVomTisch = erledigt === abschnitt.schritte.length && abschnitt.schritte.length > 0;
return (
<AbschnittHuelle
offenZuBeginn={!alleVomTisch}
nummer={abschnitt.nummer}
titel={abschnitt.titel}
erledigt={erledigt}
gesamt={abschnitt.schritte.length}
>
{abschnitt.skript ? (
<details className="mt-3">
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
Alle Befehle als Skript kopieren
</summary>
<div className="mt-2">
<Befehlsblock text={abschnitt.skript} beschriftung="Skript kopieren" />
</div>
</details>
) : null}
<ul className="mt-2 flex flex-col">
{abschnitt.schritte.map((schritt) => (
<Schritt
key={schritt.schrittId}
schritt={schritt}
laufId={laufId}
lesend={lesend}
ausfuehrlich={ausfuehrlich}
kommentare={kommentare.filter((kommentar) => kommentar.schrittergebnisId === schritt.ergebnisId)}
/>
))}
</ul>
{lesend ? null : (
<div className="mt-3">
<AbschnittNichtZutreffend laufId={laufId} abschnittId={abschnitt.id} abschnitt={abschnitt.titel} />
</div>
)}
</AbschnittHuelle>
);
}
export default async function LaufSeite({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const { id } = await params;
const { benutzer } = await sitzungErforderlich("lesen", `/laeufe/${id}`);
const ansicht = await searchParams;
const ausfuehrlich =
(Array.isArray(ansicht["ansicht"]) ? ansicht["ansicht"][0] : ansicht["ansicht"]) !== "kompakt";
const daten = await laufZumAbarbeiten(id, benutzer, benutzer.name);
// Nicht vorhanden und nicht sichtbar sind dieselbe Antwort.
if (!daten) notFound();
const kommentare = await kommentareDesLaufs(id, benutzer);
/*
* Die Personenliste nur fuer den Onboarding-Fall holen — sonst stuende in
* jedem Seitenaufbau eine Abfrage, die niemand braucht.
*/
const personen =
daten.lauf.nurFreitextName && darf(benutzer.rolle, "laeufeBearbeiten")
? await personenZurVerknuepfung(id, benutzer)
: [];
const { lauf, abschnitte, anzahlPflichtOffen } = daten;
const darfArbeiten = darf(benutzer.rolle, "laeufeBearbeiten");
const archiviert = lauf.status === LaufStatus.ARCHIVIERT;
const lesend = archiviert || !darfArbeiten;
const laufKommentare = kommentare.filter((kommentar) => kommentar.schrittergebnisId === null);
/*
* Steht ein gefuelltes `{{generiertes_passwort}}` auf der Seite, darf die
* Aktualisierung sie nicht von selbst neu bauen — beim naechsten Aufbau
* entstuende ein anderes Passwort als das, das der Techniker gerade liest.
*/
const passwortSichtbar = abschnitte.some((abschnitt) =>
abschnitt.schritte.some((schritt) => schritt.befehl?.mitPasswort === true),
);
return (
<>
{archiviert ? null : <LaufAktualisierung laufId={lauf.id} passwortSichtbar={passwortSichtbar} />}
<Link
href="/laeufe"
className="inline-flex min-h-11 items-center gap-2 text-sm text-nebentext hover:text-text"
>
<ArrowLeft className="size-4" strokeWidth={1.75} aria-hidden="true" />
Alle Läufe
</Link>
<h1 className="mt-2 text-2xl font-bold">{lauf.titel}</h1>
<p className="mt-2 text-sm text-nebentext">
{[`${lauf.vorlagenName} v${lauf.version}`, lauf.geraetName, lauf.mitarbeiterName, lauf.ticket]
.filter(Boolean)
.join(" · ")}
</p>
{archiviert ? (
<p className="mt-4 max-w-prose rounded-karte border border-linie bg-flaeche p-4 text-sm text-nebentext">
Dieser Lauf ist archiviert. Er zeigt für immer, was damals abgearbeitet wurde — geändert wird daran
nichts mehr.
</p>
) : null}
{lauf.nurFreitextName && lauf.mitarbeiterName && !lesend ? (
<LaufMitAdKontoVerknuepfen laufId={lauf.id} name={lauf.mitarbeiterName} personen={personen} />
) : null}
<Fortschrittsbalken erledigt={lauf.anzahlErledigt} gesamt={lauf.anzahlSchritte} />
<div className="mt-4 flex flex-wrap items-center gap-2">
{lauf.status === LaufStatus.OFFEN && darfArbeiten ? <LaufBeginnen laufId={lauf.id} /> : null}
{lauf.status === LaufStatus.IN_ARBEIT && darfArbeiten ? (
<LaufAbschliessen laufId={lauf.id} offen={anzahlPflichtOffen} />
) : null}
{lauf.status === LaufStatus.ABGESCHLOSSEN && darfArbeiten ? (
<LaufArchivieren laufId={lauf.id} />
) : null}
{archiviert && darf(benutzer.rolle, "archivVerwalten") ? <LaufZurueckholen laufId={lauf.id} /> : null}
<Link
href={`/laeufe/${lauf.id}?ansicht=${ausfuehrlich ? "kompakt" : "ausfuehrlich"}`}
className="flex min-h-11 items-center rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10"
>
{ausfuehrlich ? "Kompakt" : "Ausführlich"}
</Link>
</div>
<div className="mt-6 flex flex-col gap-4">
{abschnitte.map((abschnitt) => (
<Abschnitt
key={abschnitt.id}
abschnitt={abschnitt}
laufId={lauf.id}
lesend={lesend}
ausfuehrlich={ausfuehrlich}
kommentare={kommentare}
/>
))}
</div>
<section className="mt-4 rounded-karte border border-linie bg-flaeche p-4 md:p-5">
<h2 className="text-lg font-semibold">Kommentare zum Lauf</h2>
{laufKommentare.length === 0 ? (
<p className="mt-2 text-sm text-nebentext">Noch kein Kommentar.</p>
) : (
<ul className="mt-3 flex flex-col gap-2">
{laufKommentare.map((kommentar) => (
<li key={kommentar.id} className="rounded-karte bg-flaeche-hoch p-3 text-sm">
<p className="text-text">{kommentar.rumpf}</p>
<p className="mt-1 text-xs text-nebentext">
{kommentar.benutzer}, {ZEITPUNKT.format(kommentar.erstelltAm)}
</p>
</li>
))}
</ul>
)}
{lesend ? null : (
<div className="mt-4">
<KommentarSchreiben laufId={lauf.id} />
</div>
)}
</section>
</>
);
}
+210
View File
@@ -0,0 +1,210 @@
import Link from "next/link";
import { CircleDot, Clock, ListChecks, Rocket } from "lucide-react";
import { LaufStatus } from "@/generated/prisma/enums";
import { FELD, KAESTCHEN } from "@/lib/formular";
import { darf } from "@/server/auth/rollen";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { kundenEbeneSichtbar } from "@/server/mandant";
import { LISTE_HOECHSTENS, laufListe, type LaufZeile } from "@/server/queries/laeufe";
/**
* Die Laufliste (#013).
*
* Zwei Zustaende erklaeren den Alltag: `OFFEN` heisst vorbereitet und jemandem
* zugewiesen, `IN_ARBEIT` heisst, jemand sitzt gerade daran. Archivierte Laeufe
* stehen hier nicht — die gehoeren ins Archiv (#015), und eine Liste, die alles
* zeigt, zeigt nach einem halben Jahr nichts mehr.
*/
const STATUS_TEXT: Record<LaufStatus, string> = {
OFFEN: "offen",
IN_ARBEIT: "in Arbeit",
ABGESCHLOSSEN: "abgeschlossen",
ARCHIVIERT: "archiviert",
};
const DATUM = new Intl.DateTimeFormat("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" });
function Fortschritt({ zeile }: { zeile: LaufZeile }) {
const anteil = zeile.anzahlSchritte === 0 ? 0 : zeile.anzahlErledigt / zeile.anzahlSchritte;
return (
<div className="mt-2 flex items-center gap-2">
<div
className="h-1.5 w-28 overflow-hidden rounded-pille bg-flaeche-hoch"
role="progressbar"
aria-valuemin={0}
aria-valuemax={zeile.anzahlSchritte}
aria-valuenow={zeile.anzahlErledigt}
aria-label="Fortschritt"
>
<div className="h-full bg-akzent" style={{ width: `${Math.round(anteil * 100)}%` }} />
</div>
<span className="text-xs text-nebentext">
{zeile.anzahlErledigt} von {zeile.anzahlSchritte}
</span>
</div>
);
}
function Zeile({ zeile, kundenEbene }: { zeile: LaufZeile; kundenEbene: boolean }) {
const ueberfaellig =
zeile.faelligAm !== null && zeile.faelligAm < new Date() && zeile.status !== LaufStatus.ABGESCHLOSSEN;
return (
<li className="rounded-karte border border-linie bg-flaeche">
<Link href={`/laeufe/${zeile.id}`} className="block p-4">
<div className="flex flex-wrap items-center gap-2">
<CircleDot
className={`size-[18px] shrink-0 ${
zeile.status === LaufStatus.IN_ARBEIT ? "text-akzent" : "text-nebentext"
}`}
strokeWidth={1.75}
aria-hidden="true"
/>
<span className="font-medium">{zeile.titel}</span>
<span className="rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext">
{STATUS_TEXT[zeile.status]}
</span>
{ueberfaellig ? (
<span className="flex items-center gap-1 rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-faellig">
<Clock className="size-3" strokeWidth={2} aria-hidden="true" />
seit {DATUM.format(zeile.faelligAm as Date)} fällig
</span>
) : null}
{kundenEbene ? (
<span className="rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext">
{zeile.kundeName}
</span>
) : null}
</div>
<p className="mt-1 text-xs text-nebentext">
{[
`${zeile.vorlagenName} v${zeile.version}`,
zeile.geraetName,
zeile.mitarbeiterName,
zeile.ticket,
zeile.zugewiesenAn ? `für ${zeile.zugewiesenAn}` : null,
]
.filter(Boolean)
.join(" · ")}
</p>
<Fortschritt zeile={zeile} />
</Link>
</li>
);
}
export default async function LaeufeSeite({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const { benutzer } = await sitzungErforderlich("lesen", "/laeufe");
const parameter = await searchParams;
const einzeln = (name: string): string =>
(Array.isArray(parameter[name]) ? parameter[name][0] : parameter[name])?.trim() ?? "";
const status = einzeln("status");
const filter = {
suche: einzeln("suche"),
...(status === "OFFEN" || status === "IN_ARBEIT" || status === "ABGESCHLOSSEN"
? { status: status as LaufStatus }
: {}),
...(einzeln("meine") === "1" ? { zugewiesenAnId: benutzer.id } : {}),
};
const [zeilen, kundenEbene] = await Promise.all([laufListe(benutzer, filter), kundenEbeneSichtbar()]);
return (
<>
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="flex items-center gap-2 text-2xl font-bold">
<ListChecks className="size-6 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
Läufe
</h1>
{darf(benutzer.rolle, "laeufeBearbeiten") ? (
<Link
href="/laeufe/starten"
className="flex min-h-11 items-center gap-2 rounded-pille bg-akzent px-5 text-sm font-semibold text-auf-akzent md:min-h-10"
>
<Rocket className="size-[18px]" strokeWidth={2} aria-hidden="true" />
Lauf starten
</Link>
) : null}
</div>
<p className="mt-2 max-w-prose text-nebentext">
Ein Lauf wird vorbereitet, abgearbeitet und abgeschlossen. Archivierte Läufe stehen im Archiv — dort
ändert sich nichts mehr.
</p>
<form method="get" className="mt-6 flex flex-col gap-3 md:flex-row md:flex-wrap md:items-end">
<label className="flex min-w-0 flex-1 flex-col gap-1.5 text-sm md:max-w-xs">
<span className="text-nebentext">Suche</span>
<input
name="suche"
defaultValue={filter.suche}
placeholder="Titel, Ticket, Hostname oder Person"
className={FELD}
/>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Status</span>
<select name="status" defaultValue={status} className={FELD}>
<option value="">alle</option>
<option value="OFFEN">offen</option>
<option value="IN_ARBEIT">in Arbeit</option>
<option value="ABGESCHLOSSEN">abgeschlossen</option>
</select>
</label>
<label className="flex min-h-11 items-center gap-2 text-sm text-nebentext">
<input
type="checkbox"
name="meine"
value="1"
defaultChecked={einzeln("meine") === "1"}
className={KAESTCHEN}
/>
nur meine
</label>
<button
type="submit"
className="min-h-11 rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10"
>
Filtern
</button>
{filter.suche || status || einzeln("meine") ? (
<Link
href="/laeufe"
className="flex min-h-11 items-center px-2 text-sm text-nebentext underline underline-offset-2 hover:text-text md:min-h-10"
>
zurücksetzen
</Link>
) : null}
</form>
{zeilen.length === 0 ? (
<p className="mt-8 rounded-karte border border-linie bg-flaeche p-5 text-sm text-nebentext">
Kein Lauf passt zu diesen Filtern. Ein neuer Lauf beginnt bei einer Vorlage oder auf der
Geräteseite.
</p>
) : (
<>
<ul className="mt-6 flex flex-col gap-3">
{zeilen.map((zeile) => (
<Zeile key={zeile.id} zeile={zeile} kundenEbene={kundenEbene} />
))}
</ul>
{/* Eine gekappte Liste sagt das – sonst fehlte ein Lauf stillschweigend. */}
{zeilen.length === LISTE_HOECHSTENS ? (
<p className="mt-4 max-w-prose text-sm text-nebentext">
Es werden höchstens {LISTE_HOECHSTENS} Läufe gezeigt. Wenn deiner fehlt, such nach Titel,
Ticket, Hostname oder Person.
</p>
) : null}
</>
)}
</>
);
}
+74
View File
@@ -0,0 +1,74 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import { LaufStarten } from "@/components/laeufe/lauf-starten";
import { darf } from "@/server/auth/rollen";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { kundenEbeneSichtbar } from "@/server/mandant";
import { kundenListe } from "@/server/queries/kunden";
import { startAuswahl, startbareVorlagen } from "@/server/queries/laeufe";
/**
* Einen Lauf starten (#013).
*
* Die Vorbelegung kommt aus der Adresse: von der Geräteseite (#014) mit
* `?geraet=…`, von einer Vorlage mit `?vorlage=…`. So bleibt der Dialog eine
* Seite und keine drei Varianten desselben Formulars.
*/
export default async function LaufStartenSeite({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const { benutzer } = await sitzungErforderlich("lesen", "/laeufe/starten");
if (!darf(benutzer.rolle, "laeufeBearbeiten")) {
// Wer keine Läufe bearbeiten darf, soll die Seite nicht als vorhanden
// erleben — die Navigation zeigt sie ihm auch nicht.
redirect("/laeufe");
}
const parameter = await searchParams;
const einzeln = (name: string): string | undefined => {
const wert = Array.isArray(parameter[name]) ? parameter[name][0] : parameter[name];
return wert?.trim() || undefined;
};
const [vorlagen, auswahl, kundenEbene] = await Promise.all([
startbareVorlagen(benutzer),
startAuswahl(benutzer),
kundenEbeneSichtbar(),
]);
const kunden = kundenEbene ? await kundenListe(benutzer) : [];
return (
<>
<Link
href="/laeufe"
className="inline-flex min-h-11 items-center gap-2 text-sm text-nebentext hover:text-text"
>
<ArrowLeft className="size-4" strokeWidth={1.75} aria-hidden="true" />
Alle Läufe
</Link>
<h1 className="mt-2 text-2xl font-bold">Lauf starten</h1>
<p className="mt-2 max-w-prose text-nebentext">
Ein Lauf hängt für immer an der Version, mit der er gestartet wurde. Eine spätere Veröffentlichung
ändert ihn nicht — deshalb steht hier jeweils die zuletzt veröffentlichte Fassung.
</p>
<div className="mt-6 max-w-2xl">
<LaufStarten
vorlagen={vorlagen}
auswahl={auswahl}
vorbelegung={{
...(einzeln("vorlage") ? { versionId: einzeln("vorlage") as string } : {}),
...(einzeln("geraet") ? { geraetId: einzeln("geraet") as string } : {}),
...(einzeln("person") ? { mitarbeiterId: einzeln("person") as string } : {}),
}}
kundenEbene={kundenEbene}
kunden={kunden.map(({ id, name }) => ({ id, name }))}
/>
</div>
</>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { laufZustand } from "@/server/queries/laeufe";
/**
* Der Zustand eines Laufs fuer die Aktualisierung alle zehn Sekunden (#013).
*
* **Route Handler pruefen selbst.** Die Huelle unter `src/app/(app)/layout.tsx`
* deckt sie nicht ab — sie liegt in einem anderen Zweig des Baums, und der Proxy
* sieht nur, **ob** ein Cookie da ist, nie was drinsteht (CLAUDE.md).
*
* Die Antwort enthaelt **keine Befehle**. Ein `{{generiertes_passwort}}`
* entsteht bei jeder Aufloesung neu; alle zehn Sekunden mitgeliefert, tauschte
* es dem Techniker das Passwort unter den Haenden weg, waehrend er es abtippt.
* Zurueck kommen nur Status und Schrittzustaende — genau das, was zwei Browser
* am selben Lauf voneinander wissen muessen.
*/
export async function GET(
_anfrage: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
let benutzerId: string;
let rolle: Parameters<typeof laufZustand>[1]["rolle"];
try {
const { benutzer } = await rechtErforderlich("lesen");
benutzerId = benutzer.id;
rolle = benutzer.rolle;
} catch (fehler) {
if (fehler instanceof RechteFehler) {
// 401 und nicht 403: der Client soll die Aktualisierung einstellen und
// den Benutzer zur Anmeldung schicken, statt weiter zu fragen.
return Response.json({ fehler: "Nicht angemeldet." }, { status: 401 });
}
throw fehler;
}
const { id } = await params;
const zustand = await laufZustand(id, { id: benutzerId, rolle });
if (!zustand) {
// Nicht vorhanden und nicht sichtbar sind dieselbe Antwort – die
// Unterscheidung waere schon eine Auskunft ueber fremde Kunden.
return Response.json({ fehler: "Diesen Lauf gibt es nicht." }, { status: 404 });
}
return Response.json(zustand, {
// Nichts zwischenspeichern: die Antwort ist genau dann wertlos, wenn sie
// aus einem Zwischenspeicher kommt.
headers: { "Cache-Control": "no-store" },
});
}
@@ -0,0 +1,70 @@
"use client";
import { useState, type ReactNode } from "react";
import { ChevronDown } from "lucide-react";
/**
* Die auf- und zuklappbare Huelle eines Abschnitts (#013).
*
* Drei Entscheidungen, jede gegen einen ersten Entwurf:
*
* 1. **Client-Komponente mit eigenem Zustand.** Die Abarbeitungsansicht baut
* sich nach jeder gespeicherten Aenderung neu auf. Ein `offen`, das aus den
* Daten kommt, klappte dabei jedes Mal zurueck — wer einen fertigen Abschnitt
* aufklappt, um eine Notiz nachzutragen, saehe ihn beim ersten Klick wieder
* zugehen. Der Zustand im Browser ueberlebt das Neuaufbauen.
* 2. **Kein `details`.** Ein `details` klappt auch **ohne** React auf, und genau
* zwischen Auslieferung und Hydrierung geraten beide aneinander: der Browser
* oeffnet, React schliesst gleich darauf wieder zu. Ein Knopf mit
* `aria-expanded` tut vor der Hydrierung nichts — wie jeder andere Knopf
* dieser Seite, und damit vorhersehbar.
* 3. **Die Ueberschrift umschliesst den Knopf, nicht umgekehrt.** In einem
* `button` sind alle Nachfahren nur noch Beschriftung; ein `h2` darin waere
* fuer einen Screenreader keine Ueberschrift mehr, und ein Lauf verloere
* seine Gliederung. So bleibt die Ueberschrift eine Ueberschrift und der
* ganze Kopf trotzdem anklickbar.
*/
export function AbschnittHuelle({
offenZuBeginn,
nummer,
titel,
erledigt,
gesamt,
children,
}: {
/** Nur der erste Aufbau: offen, solange im Abschnitt etwas zu tun ist. */
offenZuBeginn: boolean;
nummer: string;
titel: string;
erledigt: number;
gesamt: number;
children: ReactNode;
}) {
const [offen, setzeOffen] = useState(offenZuBeginn);
return (
<section className="rounded-karte border border-linie bg-flaeche p-4 md:p-5">
<h2>
<button
type="button"
onClick={() => setzeOffen((vorher) => !vorher)}
aria-expanded={offen}
className="flex min-h-11 w-full flex-wrap items-center gap-2 text-left md:min-h-10"
>
<ChevronDown
className={`size-4 shrink-0 text-nebentext transition-transform ${offen ? "" : "-rotate-90"}`}
strokeWidth={1.75}
aria-hidden="true"
/>
<span className="mono text-sm font-semibold text-akzent">{nummer}</span>
<span className="text-nebentext">·</span>
<span className="text-lg font-semibold">{titel}</span>
<span className="text-xs font-normal text-nebentext">
{erledigt} von {gesamt}
</span>
</button>
</h2>
{offen ? children : null}
</section>
);
}
+96
View File
@@ -0,0 +1,96 @@
"use client";
import { useState } from "react";
import { Copy, KeyRound, TriangleAlert } from "lucide-react";
/**
* Ein Befehl zum Kopieren (#013).
*
* Dunkler Grund, Lime-Schrift, Monospace — er soll sich vom Fliesstext absetzen,
* weil er das einzige auf der Seite ist, das jemand **ausfuehrt**.
*
* Drei Dinge stehen daneben, wenn sie zutreffen:
*
* - **Ein unaufgeloester Platzhalter.** Er steht als `{{name}}` im Befehl und
* wuerde so auf dem Domaenencontroller landen. Eine Warnung ist hier richtig
* und eine Sperre falsch: es kann sein, dass der Techniker den Wert selbst
* einsetzt.
* - **Ein Passwort.** Es entsteht bei jeder Anzeige neu und wird nirgends
* gespeichert — wer es nicht sofort weiterreicht, hat es verloren.
* - **Ein Befehl, der sich nicht sicher fuellen liess.** Dann gibt es keinen
* Kopier-Knopf. Einen halb maskierten Befehl anzubieten waere die
* gefaehrlichste aller Antworten.
*/
export function Befehlsblock({
text,
unbekannt,
hinweis,
fehler,
beschriftung = "Befehl kopieren",
}: {
text: string;
unbekannt?: readonly string[];
hinweis?: string | null;
fehler?: string | null;
beschriftung?: string;
}) {
const [kopiert, setzeKopiert] = useState(false);
async function kopieren() {
try {
await navigator.clipboard.writeText(text);
setzeKopiert(true);
window.setTimeout(() => setzeKopiert(false), 2000);
} catch {
// Ohne Zwischenablage-Recht bleibt der Text markierbar – deshalb steht er
// ohnehin vollstaendig da, statt hinter einem Knopf zu verschwinden.
setzeKopiert(false);
}
}
return (
<div className="flex flex-col gap-2">
{fehler ? (
<p className="flex items-start gap-2 text-sm text-fehler">
<TriangleAlert className="mt-0.5 size-4 shrink-0" strokeWidth={1.75} aria-hidden="true" />
<span className="max-w-prose">{fehler}</span>
</p>
) : null}
<pre className="mono overflow-x-auto rounded-karte border border-linie bg-grund p-3 text-xs leading-relaxed text-akzent">
{text}
</pre>
{unbekannt && unbekannt.length > 0 ? (
<p className="flex items-start gap-2 text-xs text-faellig">
<TriangleAlert className="mt-0.5 size-3.5 shrink-0" strokeWidth={2} aria-hidden="true" />
<span className="max-w-prose">
{unbekannt.map((name) => `{{${name}}}`).join(", ")} konnte nicht eingesetzt werden und steht so im
Befehl. Bitte vor dem Ausführen von Hand ersetzen.
</span>
</p>
) : null}
{hinweis ? (
<p className="flex items-start gap-2 text-xs text-faellig">
<KeyRound className="mt-0.5 size-3.5 shrink-0" strokeWidth={2} aria-hidden="true" />
<span className="max-w-prose">
{hinweis} — es steht nirgends gespeichert. Sobald diese Seite sich neu aufbaut, auch nach dem
Speichern eines Schritts, steht hier ein anderes.
</span>
</p>
) : null}
{fehler ? null : (
<button
type="button"
onClick={() => void kopieren()}
className="flex min-h-11 w-fit items-center gap-2 rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10"
>
<Copy className="size-4" strokeWidth={1.75} aria-hidden="true" />
{kopiert ? "Kopiert." : beschriftung}
</button>
)}
</div>
);
}
+309
View File
@@ -0,0 +1,309 @@
"use client";
import { useActionState, useState } from "react";
import { Archive, CheckCircle2, Link2, PlayCircle, Undo2 } from "lucide-react";
import {
abschnittNichtZutreffendAktion,
kommentarHinzufuegenAktion,
laufAbschliessenAktion,
laufArchivierenAktion,
laufMitAdKontoVerknuepfenAktion,
laufOeffnenAktion,
laufZurueckholenAktion,
type AktionsErgebnis,
} from "@/server/actions/laeufe";
import { FELD } from "@/lib/formular";
const KNOPF =
"flex min-h-11 w-fit items-center gap-2 rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10";
function Meldung({ ergebnis }: { ergebnis: AktionsErgebnis }) {
if (ergebnis.fehler) {
return (
<p role="alert" className="mt-2 max-w-prose text-sm text-fehler">
{ergebnis.fehler}
</p>
);
}
if (ergebnis.erfolg) {
return (
<p role="status" className="mt-2 max-w-prose text-sm text-erledigt">
{ergebnis.erfolg}
</p>
);
}
return null;
}
/**
* „Lauf beginnen“ – der Übergang von `OFFEN` zu `IN_ARBEIT`.
*
* Erst hier entsteht `gestartetAm`. Ein zugewiesener Lauf, den jemand nur
* aufschlaegt, hat noch nicht begonnen — sonst saehe die Liste nach Arbeit aus,
* die niemand tut.
*/
export function LaufBeginnen({ laufId }: { laufId: string }) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(laufOeffnenAktion, {});
return (
<div>
<form action={aktion}>
<input type="hidden" name="laufId" value={laufId} />
<button
type="submit"
disabled={laeuft}
className="flex min-h-11 w-fit items-center gap-2 rounded-pille bg-akzent px-5 text-sm font-semibold text-auf-akzent md:min-h-10"
>
<PlayCircle className="size-[18px]" strokeWidth={2} aria-hidden="true" />
{laeuft ? "Wird geöffnet …" : "Lauf beginnen"}
</button>
</form>
<Meldung ergebnis={ergebnis} />
</div>
);
}
export function LaufAbschliessen({ laufId, offen }: { laufId: string; offen: number }) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(laufAbschliessenAktion, {});
return (
<div>
<form action={aktion}>
<input type="hidden" name="laufId" value={laufId} />
<button
type="submit"
disabled={laeuft || offen > 0}
/*
* Kein `title`: der Knopf traegt `pointer-events-none`, solange er
* gesperrt ist — ein Tooltip erschiene nie. Der Grund steht als Satz
* darunter, wo ihn auch jemand am Handy liest.
*/
className="flex min-h-11 w-fit items-center gap-2 rounded-pille bg-akzent px-5 text-sm font-semibold text-auf-akzent disabled:pointer-events-none disabled:opacity-40 md:min-h-10"
>
<CheckCircle2 className="size-[18px]" strokeWidth={2} aria-hidden="true" />
{laeuft ? "Wird abgeschlossen …" : "Lauf abschließen"}
</button>
</form>
{offen > 0 ? (
<p className="mt-2 max-w-prose text-sm text-nebentext">
Noch {offen} {offen === 1 ? "Pflichtschritt ist" : "Pflichtschritte sind"} offen. Erledigt oder
„nicht zutreffend“ zählen beide — fehlgeschlagen nicht.
</p>
) : null}
<Meldung ergebnis={ergebnis} />
</div>
);
}
/**
* Archivieren – die zweite, ausdrueckliche Handlung.
*
* Mit Rueckfrage, weil danach nichts mehr geht: kein Kommentar, keine
* nachgetragene Notiz, keine Korrektur. Nur ein Administrator holt den Lauf
* zurueck.
*/
export function LaufArchivieren({ laufId }: { laufId: string }) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(laufArchivierenAktion, {});
const [sicher, setzeSicher] = useState(false);
if (!sicher) {
return (
<button type="button" onClick={() => setzeSicher(true)} className={KNOPF}>
<Archive className="size-4" strokeWidth={1.75} aria-hidden="true" />
archivieren
</button>
);
}
return (
<div className="flex flex-col gap-2">
<p className="max-w-prose text-sm text-faellig">
Nach dem Archivieren ändert sich an diesem Lauf nichts mehr — kein Kommentar, keine Notiz, kein
Schritt. Nur ein Administrator holt ihn zurück.
</p>
<div className="flex flex-wrap gap-2">
<form action={aktion}>
<input type="hidden" name="laufId" value={laufId} />
<button
type="submit"
disabled={laeuft}
className="flex min-h-11 items-center rounded-pille border border-linie px-4 text-sm text-text hover:bg-flaeche-hoch md:min-h-10"
>
{laeuft ? "Wird archiviert …" : "Ja, archivieren"}
</button>
</form>
<button type="button" onClick={() => setzeSicher(false)} className={KNOPF}>
Abbrechen
</button>
</div>
<Meldung ergebnis={ergebnis} />
</div>
);
}
/** Zurueckholen – nur fuer Administratoren, mit Protokolleintrag. */
export function LaufZurueckholen({ laufId }: { laufId: string }) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(laufZurueckholenAktion, {});
return (
<div>
<form action={aktion}>
<input type="hidden" name="laufId" value={laufId} />
<button type="submit" disabled={laeuft} className={KNOPF}>
<Undo2 className="size-4" strokeWidth={1.75} aria-hidden="true" />
{laeuft ? "Wird zurückgeholt …" : "aus dem Archiv zurückholen"}
</button>
</form>
<Meldung ergebnis={ergebnis} />
</div>
);
}
/**
* Einen ganzen Abschnitt als nicht zutreffend vermerken.
*
* Der Fall aus dem Auftrag: im Onboarding gibt es zwei Konto-Abschnitte, und je
* nach Kunde trifft einer gar nicht zu. Achtzehn Haken einzeln mit derselben
* Begruendung zu versehen, macht niemand — und wer es nicht macht, laesst sie weg.
*/
export function AbschnittNichtZutreffend({
laufId,
abschnittId,
abschnitt,
}: {
laufId: string;
abschnittId: string;
abschnitt: string;
}) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(
abschnittNichtZutreffendAktion,
{},
);
const [offen, setzeOffen] = useState(false);
if (!offen) {
return (
<button type="button" onClick={() => setzeOffen(true)} className={`${KNOPF} text-xs`}>
Abschnitt trifft nicht zu
</button>
);
}
return (
<form action={aktion} className="flex flex-col gap-2 rounded-karte border border-linie bg-grund p-3">
<input type="hidden" name="laufId" value={laufId} />
<input type="hidden" name="abschnittId" value={abschnittId} />
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">
Warum trifft „{abschnitt}“ nicht zu? <span className="text-fehler">*</span>
</span>
<textarea
name="notiz"
rows={2}
required
placeholder="Die Begründung steht danach an jedem Schritt des Abschnitts."
className={`${FELD} py-2`}
/>
</label>
<div className="flex flex-wrap gap-2">
<button
type="submit"
disabled={laeuft}
className="flex min-h-11 items-center rounded-pille border border-linie px-4 text-sm text-text hover:bg-flaeche-hoch md:min-h-10"
>
{laeuft ? "Wird gesetzt …" : "Abschnitt vermerken"}
</button>
<button type="button" onClick={() => setzeOffen(false)} className={KNOPF}>
Abbrechen
</button>
</div>
<Meldung ergebnis={ergebnis} />
</form>
);
}
/** Ein Kommentar am Lauf oder an einem Schritt. */
export function KommentarSchreiben({
laufId,
schrittergebnisId,
}: {
laufId: string;
schrittergebnisId?: string | null;
}) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(
kommentarHinzufuegenAktion,
{},
);
return (
<form action={aktion} className="flex flex-col gap-2">
<input type="hidden" name="laufId" value={laufId} />
{schrittergebnisId ? <input type="hidden" name="schrittergebnisId" value={schrittergebnisId} /> : null}
<textarea
name="rumpf"
rows={2}
required
placeholder="Was sollte man später wissen?"
className={`${FELD} py-2`}
/>
<button type="submit" disabled={laeuft} className={`${KNOPF} self-start`}>
{laeuft ? "Wird gespeichert …" : "Kommentar hinzufügen"}
</button>
<Meldung ergebnis={ergebnis} />
</form>
);
}
/**
* Einen Lauf nachtraeglich an ein Konto aus dem Verzeichnis haengen (#013).
*
* Sichtbar nur im Onboarding-Fall: der Lauf traegt einen Freitextnamen, weil es
* die Person beim Start noch nicht gab. Danach gehoert die Arbeit an die Person
* — sonst findet sie in ihrer Historie nichts wieder.
*
* Zwei Wege, weil es zwei Lagen gibt: die Person steht schon in der Liste (dann
* waehlt man sie), oder sie ist gerade erst im Verzeichnis entstanden und noch
* nicht abgeglichen (dann traegt man den UPN ein, so wie er dort steht).
*/
export function LaufMitAdKontoVerknuepfen({
laufId,
name,
personen,
}: {
laufId: string;
name: string;
personen: readonly { id: string; anzeigename: string }[];
}) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(
laufMitAdKontoVerknuepfenAktion,
{},
);
return (
<details className="mt-4 rounded-karte border border-linie bg-flaeche p-4">
<summary className="flex min-h-11 cursor-pointer list-none items-center gap-2 text-sm text-nebentext hover:text-text md:min-h-10">
<Link2 className="size-4" strokeWidth={1.75} aria-hidden="true" />„{name}“ mit einem AD-Konto
verknüpfen
</summary>
<form action={aktion} className="mt-3 flex flex-col gap-3">
<input type="hidden" name="laufId" value={laufId} />
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Person aus dem Verzeichnis</span>
<select name="mitarbeiterId" defaultValue="" className={FELD}>
<option value="">bitte wählen</option>
{personen.map((person) => (
<option key={person.id} value={person.id}>
{person.anzeigename}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">… oder UPN, wenn das Konto neu ist</span>
<input name="upn" placeholder="vorname.nachname@firma.de" className={FELD} />
</label>
<button type="submit" disabled={laeuft} className={KNOPF}>
{laeuft ? "Wird verknüpft …" : "Verknüpfen"}
</button>
<Meldung ergebnis={ergebnis} />
</form>
</details>
);
}
@@ -0,0 +1,130 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { RefreshCw } from "lucide-react";
/**
* Holt alle zehn Sekunden den Zustand des Laufs (#013).
*
* Damit sehen zwei Browser am selben Lauf die Arbeit des anderen — der Fall aus
* dem Auftrag: zwei Techniker teilen sich ein Onboarding.
*
* Vier Entscheidungen stecken darin:
*
* 1. **Pause bei verstecktem Tab.** Ein Lauf bleibt oft stundenlang offen im
* Hintergrund; ohne Pause waeren das 360 Abfragen je Stunde und Tab, fuer
* niemanden.
* 2. **`router.refresh()` statt eigener Zustandsverwaltung.** Die Seite ist eine
* Server-Komponente; ein Neuladen ihrer Daten ist genau das, was gebraucht
* wird, und es gibt keine zweite Wahrheit im Browser. Aktualisiert wird nur,
* wenn sich wirklich etwas geaendert hat — sonst wuerde die Seite alle zehn
* Sekunden neu rendern und dabei Eingaben verlieren, an denen jemand tippt.
* 3. **Steht ein generiertes Passwort auf der Seite, wird nicht von selbst
* neu geladen**, sondern gefragt. Das Passwort entsteht bei jedem Aufbau der
* Seite neu; ein Neuladen im Hintergrund tauschte es dem Techniker zwischen
* Lesen und Einfuegen aus. Die Antwort des Pollings traegt deshalb keine
* Befehle (siehe `laufZustand`) — und diese Zusage waere nichts wert, wenn
* das Polling stattdessen die ganze Seite neu bauen liesse.
* 4. **Abbruch bei 401 und 404.** Eine abgelaufene Anmeldung oder ein
* verschwundener Lauf sind kein Grund weiterzufragen.
*/
/**
* Was ein geaenderter Zustand ausloesen soll.
*
* Eine eigene Funktion, damit die Entscheidung geprueft werden kann: fuer eine
* Client-Komponente gibt es in diesem Projekt bewusst keine DOM-Umgebung, und
* eine Regel, die nur im Effekt steht, ist eine Regel ohne Test.
*/
export function reaktionAufAenderung(passwortSichtbar: boolean): "neu-laden" | "fragen" {
return passwortSichtbar ? "fragen" : "neu-laden";
}
export function LaufAktualisierung({
laufId,
passwortSichtbar = false,
}: {
laufId: string;
/** Auf der Seite steht ein gefuelltes `{{generiertes_passwort}}`. */
passwortSichtbar?: boolean;
}) {
const router = useRouter();
/*
* Der zuletzt gesehene Zustand als Referenz und nicht als State: ein
* `useState` daraus loeste bei jedem Abruf ein Rendern aus — auch dann, wenn
* sich nichts geaendert hat, und genau das soll es nicht.
*/
const letzterStand = useRef<string | null>(null);
const [neues, setzeNeues] = useState(false);
useEffect(() => {
let abgebrochen = false;
let zeitgeber: ReturnType<typeof setTimeout> | undefined;
async function holen(): Promise<void> {
if (abgebrochen || document.visibilityState === "hidden") {
plane();
return;
}
try {
const antwort = await fetch(`/api/laeufe/${laufId}/zustand`, { cache: "no-store" });
if (antwort.status === 401 || antwort.status === 404) {
abgebrochen = true;
return;
}
if (antwort.ok) {
const stand = JSON.stringify(await antwort.json());
if (letzterStand.current !== null && letzterStand.current !== stand) {
if (reaktionAufAenderung(passwortSichtbar) === "fragen") setzeNeues(true);
else router.refresh();
}
letzterStand.current = stand;
}
} catch {
// Netz weg, Rechner im Standby: einfach beim naechsten Mal wieder.
}
plane();
}
function plane(): void {
if (abgebrochen) return;
zeitgeber = setTimeout(() => void holen(), 10_000);
}
void holen();
// Kommt der Tab zurueck, sofort fragen statt bis zu zehn Sekunden zu warten.
const beiSichtbarkeit = () => {
if (document.visibilityState === "visible") void holen();
};
document.addEventListener("visibilitychange", beiSichtbarkeit);
return () => {
abgebrochen = true;
if (zeitgeber) clearTimeout(zeitgeber);
document.removeEventListener("visibilitychange", beiSichtbarkeit);
};
}, [laufId, router, passwortSichtbar]);
// Ohne Passwort auf der Seite ist die Wirkung unsichtbar: sie erneuert sich.
if (!neues) return null;
return (
<div className="sticky top-2 z-10 mb-4 flex flex-wrap items-center gap-3 rounded-karte border border-akzent/40 bg-akzent-flaeche p-3 text-sm">
<span className="min-w-0 flex-1">
Jemand anderes hat an diesem Lauf etwas geändert. Beim Neuladen entsteht ein{" "}
<strong>neues Passwort</strong> — kopiere das angezeigte vorher, wenn du es noch brauchst.
</span>
<button
type="button"
onClick={() => {
setzeNeues(false);
router.refresh();
}}
className="flex min-h-11 shrink-0 items-center gap-2 rounded-pille bg-akzent px-4 font-semibold text-auf-akzent md:min-h-10"
>
<RefreshCw className="size-4" strokeWidth={2} aria-hidden="true" />
Neu laden
</button>
</div>
);
}
+366
View File
@@ -0,0 +1,366 @@
"use client";
import { useActionState, useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { Rocket, Wand } from "lucide-react";
import { FELD } from "@/lib/formular";
import { laufAnlegenAktion, namensvorschlaegeAktion, type AktionsErgebnis } from "@/server/actions/laeufe";
import type { StartAuswahl, StartbareVorlage } from "@/server/queries/laeufe";
/**
* Der Start-Dialog (#013).
*
* Er fragt in der Reihenfolge, in der jemand denkt: **Was** wird gemacht, **für
* wen**, **an welchem Gerät** — und erst dann das Beiwerk. Der Titel bleibt
* leer, wenn niemand ihn ändert; der Dienst bildet ihn dann aus Vorlage, Gerät
* oder Person und Datum. Ein Vorschlag, den die Oberfläche selbst zusammenbaut,
* wäre eine zweite Wahrheit, die irgendwann von der ersten abweicht.
*
* **Der Besitzer des Geräts wird als Person vorgeschlagen.** Wer ein Gerät
* auswählt, meint fast immer die Person, der es gehört — und wer das nicht
* meint, ändert es mit einem Klick.
*
* **Anmeldenamen schlägt der Server vor, nicht der Browser.** Die Namensregeln
* hängen am Kunden und die Eindeutigkeit an der Datenbank; nachgebaut im Client
* wären sie eine zweite Wahrheit, die beim ersten abweichenden Kundenschema
* auffliegt. Gefragt wird erst auf Klick — jeder Tastendruck im Namensfeld wäre
* eine Abfrage zu viel.
*/
export function LaufStarten({
vorlagen,
auswahl,
vorbelegung,
kundenEbene,
kunden,
}: {
vorlagen: readonly StartbareVorlage[];
auswahl: StartAuswahl;
/** Vorbelegung je Aufrufort: von der Geräteseite kommt das Gerät mit. */
vorbelegung: { versionId?: string; geraetId?: string; mitarbeiterId?: string };
kundenEbene: boolean;
kunden: readonly { id: string; name: string }[];
}) {
const router = useRouter();
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis & { laufId?: string }, FormData>(
laufAnlegenAktion,
{},
);
const [versionId, setzeVersion] = useState(vorbelegung.versionId ?? "");
const [geraetId, setzeGeraet] = useState(vorbelegung.geraetId ?? "");
const [mitarbeiterId, setzeMitarbeiter] = useState(vorbelegung.mitarbeiterId ?? "");
const [freitext, setzeFreitext] = useState("");
const [kundeId, setzeKunde] = useState("");
const [variablen, setzeVariablen] = useState<Record<string, string>>({});
const vorlage = useMemo(
() => vorlagen.find((eintrag) => eintrag.versionId === versionId),
[vorlagen, versionId],
);
// Nach dem Anlegen direkt in den Lauf – wer ihn startet, will ihn abarbeiten.
useEffect(() => {
if (ergebnis.laufId) router.push(`/laeufe/${ergebnis.laufId}`);
}, [ergebnis.laufId, router]);
/** Die Variablen der Vorlage, für die es Namensregeln gibt. */
const namensfelder = useMemo(
() =>
(vorlage?.variablen ?? [])
.map((variable) => variable.schluessel)
.filter((schluessel): schluessel is "upn" | "sam" | "mail" | "hostname" =>
["upn", "sam", "mail", "hostname"].includes(schluessel),
),
[vorlage],
);
const [holt, setzeHolt] = useState(false);
const [vorschlagFehler, setzeVorschlagFehler] = useState<string | null>(null);
async function vorschlaegeHolen() {
setzeHolt(true);
setzeVorschlagFehler(null);
try {
const name = freitext.trim() || personName(auswahl, mitarbeiterId);
const antwort = await namensvorschlaegeAktion(name, kundeId || null);
if (antwort.fehler) {
setzeVorschlagFehler(antwort.fehler);
return;
}
const vorschlaege = antwort.vorschlaege ?? {};
setzeVariablen((alt) => {
const neu = { ...alt };
for (const feld of namensfelder) {
const wert = vorschlaege[feld];
// Nur füllen, was leer ist: eine eingetippte Adresse ist eine
// Entscheidung, ein Vorschlag nur ein Angebot.
if (wert && !neu[feld]) neu[feld] = wert;
}
return neu;
});
if (Object.keys(vorschlaege).length === 0) {
setzeVorschlagFehler(
"Für diesen Kunden sind keine Namensregeln hinterlegt — die stellt ein Administrator unter Einstellungen ein.",
);
}
} finally {
setzeHolt(false);
}
}
function geraetWaehlen(neu: string) {
setzeGeraet(neu);
const besitzer = auswahl.geraete.find((geraet) => geraet.id === neu)?.besitzerId;
// Nur vorschlagen, nicht überschreiben: wer schon eine Person gewählt hat,
// hat sich etwas dabei gedacht.
if (besitzer && mitarbeiterId === "" && freitext === "") setzeMitarbeiter(besitzer);
}
return (
<form action={aktion} className="flex flex-col gap-5">
<input type="hidden" name="variablen" value={JSON.stringify(variablen)} />
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Vorlage</span>
<select
name="vorlagenVersionId"
required
value={versionId}
onChange={(ereignis) => setzeVersion(ereignis.target.value)}
className={FELD}
>
<option value="">bitte wählen</option>
{vorlagen.map((eintrag) => (
<option key={eintrag.versionId} value={eintrag.versionId}>
{eintrag.kategorie ? `${eintrag.kategorie} · ` : ""}
{eintrag.name} (v{eintrag.version})
</option>
))}
</select>
{vorlagen.length === 0 ? (
<span className="text-xs text-faellig">
Es gibt noch keine veröffentlichte Vorlage. Ein Entwurf ist noch niemandes Arbeitsanweisung — erst
das Veröffentlichen macht ihn dazu.
</span>
) : null}
</label>
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Gerät (optional)</span>
<select
name="geraetId"
value={geraetId}
onChange={(ereignis) => geraetWaehlen(ereignis.target.value)}
className={FELD}
>
<option value="">kein Gerät</option>
{auswahl.geraete.map((geraet) => (
<option key={geraet.id} value={geraet.id}>
{geraet.anzeige}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Person (optional)</span>
<select
name="mitarbeiterId"
value={mitarbeiterId}
onChange={(ereignis) => {
setzeMitarbeiter(ereignis.target.value);
if (ereignis.target.value) setzeFreitext("");
}}
className={FELD}
>
<option value="">keine Person aus dem Verzeichnis</option>
{auswahl.personen.map((person) => (
<option key={person.id} value={person.id}>
{person.anzeigename}
</option>
))}
</select>
</label>
</div>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">… oder Name als Freitext</span>
<input
name="mitarbeiterName"
value={freitext}
onChange={(ereignis) => {
setzeFreitext(ereignis.target.value);
if (ereignis.target.value) setzeMitarbeiter("");
}}
placeholder="Für ein Onboarding, bevor die Person im Verzeichnis steht"
className={FELD}
/>
<span className="text-xs text-nebentext">
Eines von beidem. Der Freitextname lässt sich später mit dem AD-Konto verknüpfen.
</span>
</label>
{vorlage && vorlage.variablen.length > 0 ? (
<fieldset className="flex flex-col gap-4 rounded-karte border border-linie p-4">
<legend className="px-2 text-sm text-nebentext">Angaben zu diesem Lauf</legend>
{vorlage.variablen.map((variable) => (
<label key={variable.schluessel} className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">
{variable.beschriftung}
{variable.pflicht ? <span className="text-fehler"> *</span> : null}
</span>
{variable.optionen.length > 0 ? (
<select
required={variable.pflicht}
value={variablen[variable.schluessel] ?? ""}
onChange={(ereignis) =>
setzeVariablen((alt) => ({ ...alt, [variable.schluessel]: ereignis.target.value }))
}
className={FELD}
>
<option value="">bitte wählen</option>
{variable.optionen.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
) : (
<input
type={variable.typ === "ZAHL" ? "number" : variable.typ === "DATUM" ? "date" : "text"}
required={variable.pflicht}
value={variablen[variable.schluessel] ?? ""}
onChange={(ereignis) =>
setzeVariablen((alt) => ({ ...alt, [variable.schluessel]: ereignis.target.value }))
}
className={FELD}
/>
)}
</label>
))}
{namensfelder.length > 0 ? (
<div className="flex flex-col gap-2">
<button
type="button"
onClick={() => void vorschlaegeHolen()}
disabled={holt || (freitext.trim() === "" && mitarbeiterId === "")}
className="flex min-h-11 w-fit items-center gap-2 rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text disabled:pointer-events-none disabled:opacity-40 md:min-h-10"
>
<Wand className="size-4" strokeWidth={1.75} aria-hidden="true" />
{holt ? "Wird gebildet …" : "Namen aus den Regeln vorschlagen"}
</button>
<p className="max-w-prose text-xs text-nebentext">
Füllt {namensfelder.map((feld) => `„${feld}“`).join(", ")} nach den Namensregeln — ausgefüllte
Felder bleiben stehen. Verbindlich wird ein Name erst beim Anlegen im Verzeichnis.
</p>
{vorschlagFehler ? (
<p role="alert" className="max-w-prose text-xs text-faellig">
{vorschlagFehler}
</p>
) : null}
</div>
) : null}
</fieldset>
) : null}
<details>
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
Titel, Ticket, Abteilung, Zuweisung und Fälligkeit
</summary>
<div className="mt-3 flex flex-col gap-4">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Titel</span>
<input
name="titel"
placeholder="Bleibt leer: Vorlage – Gerät oder Person – Datum"
className={FELD}
/>
</label>
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Ticket</span>
<input name="ticket" placeholder="INC-4711" className={FELD} />
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Abteilung</span>
<select name="abteilungId" defaultValue="" className={FELD}>
<option value="">keine</option>
{auswahl.abteilungen.map((abteilung) => (
<option key={abteilung.id} value={abteilung.id}>
{abteilung.name}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Zuweisen an</span>
<select name="zugewiesenAnId" defaultValue="" className={FELD}>
<option value="">niemanden</option>
{auswahl.bearbeiter.map((person) => (
<option key={person.id} value={person.id}>
{person.name}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Fällig am</span>
<input name="faelligAm" type="date" className={FELD} />
</label>
</div>
<p className="max-w-prose text-xs text-nebentext">
Die Abteilung speist <code className="mono">{"{{ou}}"}</code> und{" "}
<code className="mono">{"{{gruppen}}"}</code> in den Befehlen dieses Laufs.
</p>
</div>
</details>
{kundenEbene && kunden.length > 0 ? (
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Kunde</span>
<select
name="kundeId"
required
value={kundeId}
onChange={(ereignis) => setzeKunde(ereignis.target.value)}
className={FELD}
>
<option value="" disabled>
bitte wählen
</option>
{kunden.map((kunde) => (
<option key={kunde.id} value={kunde.id}>
{kunde.name}
</option>
))}
</select>
</label>
) : null}
{ergebnis.fehler ? (
<p role="alert" className="max-w-prose text-sm text-fehler">
{ergebnis.fehler}
</p>
) : null}
<button
type="submit"
disabled={laeuft || ergebnis.laufId !== undefined}
className="flex min-h-11 w-fit items-center gap-2 rounded-pille bg-akzent px-5 text-sm font-semibold text-auf-akzent disabled:pointer-events-none disabled:opacity-40 md:min-h-10"
>
<Rocket className="size-[18px]" strokeWidth={2} aria-hidden="true" />
{laeuft || ergebnis.laufId ? "Wird angelegt …" : "Lauf anlegen"}
</button>
<p className="max-w-prose text-xs text-nebentext">
Der Lauf beginnt als <strong>offen</strong> — das ist die Zuweisung. Gestartet wird er erst, wenn
jemand ihn zur Bearbeitung öffnet.
</p>
</form>
);
}
/** Der Anzeigename der gewaehlten Person – die Grundlage der Namensvorschlaege. */
function personName(auswahl: StartAuswahl, mitarbeiterId: string): string {
return auswahl.personen.find((person) => person.id === mitarbeiterId)?.anzeigename ?? "";
}
+139
View File
@@ -0,0 +1,139 @@
"use client";
import { useActionState, useState } from "react";
import { SchrittTyp, SchrittZustand } from "@/generated/prisma/enums";
import { FELD } from "@/lib/formular";
import { schrittergebnisSetzenAktion, type AktionsErgebnis } from "@/server/actions/laeufe";
/**
* Das Eingabefeld eines Schritts, passend zu seinem Typ (#013).
*
* Gespeichert wird mit einem eigenen Knopf und nicht bei jedem Tastendruck: ein
* halb getippter Wert soll nicht in die Datenbank, und ein Lauf wird oft am
* Handy mit wackligem Netz abgearbeitet.
*
* Ein `ZAHL`-Wert ausserhalb von Min/Max wird **markiert, nicht blockiert**. Die
* Grenzen sind eine Erwartung, kein Gesetz — ein Rechner mit 4 GB statt der
* erwarteten 8 ist ein Befund, den der Lauf festhalten soll, kein Eingabefehler.
*/
export function SchrittWert({
laufId,
vorlagenSchrittId,
typ,
konfig,
wert,
zustand,
lesend,
}: {
laufId: string;
vorlagenSchrittId: string;
typ: SchrittTyp;
konfig: Record<string, unknown>;
wert: unknown;
zustand: SchrittZustand;
lesend: boolean;
}) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(
schrittergebnisSetzenAktion,
{},
);
const [eingabe, setzeEingabe] = useState(wert === null || wert === undefined ? "" : String(wert));
if (typ === SchrittTyp.HAKEN || typ === SchrittTyp.BEFEHL) return null;
if (lesend) {
return eingabe ? (
<p className="mono text-sm text-text">{eingabe}</p>
) : (
<p className="text-sm text-nebentext">kein Wert erfasst</p>
);
}
const zahl = typ === SchrittTyp.ZAHL ? Number(eingabe) : Number.NaN;
const min = typeof konfig["min"] === "number" ? konfig["min"] : undefined;
const max = typeof konfig["max"] === "number" ? konfig["max"] : undefined;
const ausserhalb =
!Number.isNaN(zahl) && ((min !== undefined && zahl < min) || (max !== undefined && zahl > max));
return (
<form action={aktion} className="flex flex-col gap-2">
<input type="hidden" name="laufId" value={laufId} />
<input type="hidden" name="vorlagenSchrittId" value={vorlagenSchrittId} />
{/* Der Zustand bleibt, wie er ist – hier wird ein Wert erfasst, kein Haken gesetzt. */}
<input type="hidden" name="zustand" value={zustand} />
<div className="flex flex-wrap items-end gap-2">
<label className="flex min-w-0 flex-1 flex-col gap-1.5 text-sm md:max-w-sm">
<span className="text-nebentext">{beschriftungFuer(typ, konfig)}</span>
{typ === SchrittTyp.AUSWAHL ? (
<select
name="wert"
value={eingabe}
onChange={(ereignis) => setzeEingabe(ereignis.target.value)}
className={FELD}
>
<option value="">bitte wählen</option>
{optionenAus(konfig).map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
) : typ === SchrittTyp.TEXT && konfig["mehrzeilig"] === true ? (
<textarea
name="wert"
rows={3}
value={eingabe}
onChange={(ereignis) => setzeEingabe(ereignis.target.value)}
className={`${FELD} py-2`}
/>
) : (
<input
name="wert"
type={typ === SchrittTyp.ZAHL ? "number" : typ === SchrittTyp.DATUM ? "date" : "text"}
value={eingabe}
onChange={(ereignis) => setzeEingabe(ereignis.target.value)}
className={FELD}
/>
)}
</label>
<button
type="submit"
disabled={laeuft}
className="flex min-h-11 items-center rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10"
>
{laeuft ? "Wird gespeichert …" : "Wert speichern"}
</button>
</div>
{ausserhalb ? (
<p className="text-xs text-faellig">
Erwartet werden {min ?? "–"} bis {max ?? "–"}
{typeof konfig["einheit"] === "string" ? ` ${konfig["einheit"]}` : ""}. Der Wert wird trotzdem
gespeichert — ein Befund ist kein Eingabefehler.
</p>
) : null}
{ergebnis.fehler ? (
<p role="alert" className="max-w-prose text-sm text-fehler">
{ergebnis.fehler}
</p>
) : null}
</form>
);
}
function beschriftungFuer(typ: SchrittTyp, konfig: Record<string, unknown>): string {
if (typ === SchrittTyp.ZAHL) {
const einheit = typeof konfig["einheit"] === "string" ? ` in ${konfig["einheit"]}` : "";
return `Wert${einheit}`;
}
if (typ === SchrittTyp.DATUM) return "Datum";
if (typ === SchrittTyp.AUSWAHL) return "Auswahl";
return "Wert";
}
function optionenAus(konfig: Record<string, unknown>): readonly string[] {
const roh = konfig["optionen"];
return Array.isArray(roh) ? roh.filter((eintrag): eintrag is string => typeof eintrag === "string") : [];
}
+210
View File
@@ -0,0 +1,210 @@
"use client";
import { useActionState, useState } from "react";
import { Check, CircleSlash, TriangleAlert } from "lucide-react";
import { SchrittZustand } from "@/generated/prisma/enums";
import { FELD } from "@/lib/formular";
import { schrittergebnisSetzenAktion, type AktionsErgebnis } from "@/server/actions/laeufe";
/**
* Der Zustand eines Schritts – einzeln gespeichert, nicht als Gesamtformular
* (#013).
*
* Der Grund steht im Auftrag und ist handfest: ein Techniker arbeitet einen Lauf
* ueber Stunden ab, oft am Handy, oft mit wackligem WLAN. Ein Gesamtformular
* hiesse, dass ein verlorener Klick auf „Speichern“ zwei Stunden Haken kostet.
*
* `NICHT_ZUTREFFEND` und `FEHLGESCHLAGEN` verlangen eine Notiz. Das Feld
* erscheint deshalb, **bevor** gespeichert wird — sonst liefe der Benutzer in
* eine Absage des Dienstes, ohne zu wissen, was fehlt.
*
* **Ein Klick auf die bereits gewaehlte Pille nimmt zurueck, und zwar sofort.**
* Das Zuruecknehmen braucht keine Begruendung — es entfernt ja gerade die
* Aussage, die eine gebraucht haette. Vorher war diese Pille in dem Fall ein
* `type="button"`: der Klick setzte den Zustand nur im Browser zurueck, schickte
* nichts ab, und mit ihm verschwand der Speichern-Knopf. Auf dem Bildschirm war
* nichts mehr gewaehlt, in der Datenbank stand weiter `NICHT_ZUTREFFEND`
* (Reviewbefund zu #013).
*
* Der Zustand haengt am **Knopf**, nicht an einem versteckten Feld: ein
* abschickender Knopf gibt `name`/`value` mit, und damit steht im Formular
* genau das, was der Klick bedeutet — ohne auf ein erneutes Rendern zu warten.
*/
export function SchrittZustandWaehlen({
laufId,
vorlagenSchrittId,
zustand,
notiz,
lesend,
}: {
laufId: string;
vorlagenSchrittId: string;
zustand: SchrittZustand;
notiz: string | null;
lesend: boolean;
}) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(
schrittergebnisSetzenAktion,
{},
);
const [gewaehlt, setzeGewaehlt] = useState<SchrittZustand>(zustand);
const [notizText, setzeNotiz] = useState(notiz ?? "");
/*
* Ein Zustand, der von aussen kommt — die Aktualisierung alle zehn Sekunden,
* ein zweiter Techniker am selben Lauf —, gewinnt gegen die eigene
* Vorauswahl.
*
* Angeglichen wird **waehrend des Renderns** und nicht in einem Effekt: React
* verwirft den begonnenen Durchlauf und rendert sofort neu, ohne dass
* dazwischen etwas Falsches auf dem Bildschirm steht. Ein `useEffect` dafuer
* loest eine zweite Renderwelle aus — und ESLint weist ihn zu Recht ab.
*/
const [zuletztVonAussen, setzeZuletztVonAussen] = useState(zustand);
if (zustand !== zuletztVonAussen) {
setzeZuletztVonAussen(zustand);
setzeGewaehlt(zustand);
}
/** Eine Wahl, die noch nicht gespeichert ist – das Begruendungsfeld steht offen. */
const offeneWahl = gewaehlt !== zustand;
const brauchtNotiz =
gewaehlt === SchrittZustand.NICHT_ZUTREFFEND || gewaehlt === SchrittZustand.FEHLGESCHLAGEN;
if (lesend) {
return (
<div className="flex flex-wrap items-center gap-2 text-sm text-nebentext">
<ZustandKennzeichen zustand={zustand} />
{notiz ? <span className="max-w-prose">{notiz}</span> : null}
</div>
);
}
return (
<form action={aktion} className="flex flex-col gap-2">
<input type="hidden" name="laufId" value={laufId} />
<input type="hidden" name="vorlagenSchrittId" value={vorlagenSchrittId} />
<div className="flex flex-wrap items-center gap-2">
{(
[
[SchrittZustand.ERLEDIGT, "erledigt", Check],
[SchrittZustand.NICHT_ZUTREFFEND, "nicht zutreffend", CircleSlash],
[SchrittZustand.FEHLGESCHLAGEN, "fehlgeschlagen", TriangleAlert],
] as const
).map(([wert, beschriftung, Symbol]) => {
const aktiv = gewaehlt === wert;
/*
* Drei Faelle, und die Reihenfolge ist wichtig:
*
* 1. **Abbrechen** — dieser Zustand ist gewaehlt, aber noch nicht
* gespeichert: der Klick nimmt die Wahl zurueck und raeumt das
* Begruendungsfeld weg.
* 2. **Nachfragen** — `NICHT_ZUTREFFEND` und `FEHLGESCHLAGEN` brauchen
* eine Begruendung, also erst das Feld zeigen, dann speichern.
* 3. **Abschicken** — alles andere, und zwar sofort. Ist der Zustand
* schon gespeichert, schickt der Klick `OFFEN`: das Zuruecknehmen
* braucht keine Begruendung, es entfernt ja gerade eine.
*/
const abbrechen = offeneWahl && aktiv;
const fragtNach = !abbrechen && wert !== SchrittZustand.ERLEDIGT && zustand !== wert;
const schicktAb = !abbrechen && !fragtNach;
/*
* Der abgeschickte Wert haengt am **gespeicherten** Zustand, und
* `onClick` ruehrt ihn nicht an. React baut das Formular erst nach dem
* erneuten Rendern zusammen: stellte der Klick den Knopf selbst um,
* kaeme der umgestellte Knopf an — im gebauten Stand nachgestellt, und
* es kam wahlweise „offen“ oder gar kein Feld `zustand` an.
*/
return (
<button
key={wert}
type={schicktAb ? "submit" : "button"}
name={schicktAb ? "zustand" : undefined}
value={schicktAb ? (zustand === wert ? SchrittZustand.OFFEN : wert) : undefined}
onClick={schicktAb ? undefined : () => setzeGewaehlt(abbrechen ? zustand : wert)}
disabled={laeuft}
aria-pressed={aktiv}
/*
* „Erledigt“ ist die Lime-Kachel mit dunklem Haken, die der
* Auftrag verlangt: der Akzent gehoert dem Fortschritt.
* „Nicht zutreffend“ ist auch vom Tisch, aber kein Fortschritt —
* deshalb neutral, sonst saehen beide gleich nach Erfolg aus.
*/
className={`flex min-h-11 items-center gap-2 rounded-pille px-4 text-sm md:min-h-10 ${
aktiv
? wert === SchrittZustand.ERLEDIGT
? "bg-akzent font-semibold text-auf-akzent"
: wert === SchrittZustand.FEHLGESCHLAGEN
? "bg-fehler/15 font-semibold text-fehler"
: "bg-flaeche-hoch font-semibold text-text"
: "border border-linie text-nebentext hover:bg-flaeche-hoch hover:text-text"
}`}
>
<Symbol className="size-4" strokeWidth={2} aria-hidden="true" />
{beschriftung}
</button>
);
})}
</div>
{brauchtNotiz ? (
<div className="flex flex-col gap-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">
Begründung <span className="text-fehler">*</span>
</span>
<textarea
name="notiz"
rows={2}
required
value={notizText}
onChange={(ereignis) => setzeNotiz(ereignis.target.value)}
placeholder="Warum trifft dieser Schritt nicht zu, oder woran ist er gescheitert?"
className={`${FELD} py-2`}
/>
</label>
<button
type="submit"
name="zustand"
value={gewaehlt}
disabled={laeuft || notizText.trim() === ""}
className="flex min-h-11 w-fit items-center rounded-pille bg-akzent px-5 text-sm font-semibold text-auf-akzent disabled:pointer-events-none disabled:opacity-40 md:min-h-10"
>
{laeuft ? "Wird gespeichert …" : "Mit Begründung speichern"}
</button>
<p className="max-w-prose text-xs text-nebentext">
Ohne Begründung steht im Archiv ein übersprungener Schritt ohne Grund — und genau das macht ein
Archiv wertlos.
</p>
</div>
) : null}
{ergebnis.fehler ? (
<p role="alert" className="max-w-prose text-sm text-fehler">
{ergebnis.fehler}
</p>
) : null}
</form>
);
}
export function ZustandKennzeichen({ zustand }: { zustand: SchrittZustand }) {
if (zustand === SchrittZustand.OFFEN) return null;
const text: Record<Exclude<SchrittZustand, "OFFEN">, string> = {
ERLEDIGT: "erledigt",
NICHT_ZUTREFFEND: "nicht zutreffend",
FEHLGESCHLAGEN: "fehlgeschlagen",
};
return (
<span
className={`rounded-pille px-2.5 py-1 text-xs ${
zustand === SchrittZustand.FEHLGESCHLAGEN
? "bg-fehler/15 text-fehler"
: "bg-flaeche-hoch text-nebentext"
}`}
>
{text[zustand]}
</span>
);
}
+1 -1
View File
@@ -32,7 +32,7 @@ export type NavEintrag = {
export const NAVIGATION: readonly NavEintrag[] = [
{ pfad: "/", beschriftung: "Übersicht", symbol: "LayoutDashboard", verfuegbar: true },
{ pfad: "/laeufe", beschriftung: "Läufe", symbol: "ListChecks", verfuegbar: false, ausIssue: 13 },
{ pfad: "/laeufe", beschriftung: "Läufe", symbol: "ListChecks", verfuegbar: true },
{ pfad: "/vorlagen", beschriftung: "Vorlagen", symbol: "FileText", verfuegbar: true },
{
pfad: "/mitarbeiter",
+16 -1
View File
@@ -19,6 +19,12 @@ import { PASSWORT_IM_DRUCK, passwortErzeugen } from "@/lib/passwort";
* „falsch geschrieben“ und „hier gibt es das nicht“.
* 3. **Das generierte Passwort entsteht erst beim Ersetzen** und wird nie
* zurueckgegeben. Es gibt keinen Weg, es nachtraeglich zu erfragen.
* 4. **In einem Text steht ein Passwort, nicht zwei.** Wer
* `{{generiertes_passwort}}` zweimal schreibt — einmal fuer `-AccountPassword`
* und einmal fuer die Zeile, die es dem Benutzer mitteilt —, meint dasselbe
* Passwort. Zwei verschiedene hiessen: das Konto bekommt A, der Benutzer
* erfaehrt B. Wer ueber einen ganzen Seitenaufbau hinweg dasselbe braucht,
* reicht es als `passwort` herein.
*/
/**
@@ -74,9 +80,17 @@ export function platzhalterErsetzen(
text: string,
werte: Readonly<Record<string, string>>,
verwendung: Verwendung = { art: "anzeige" },
/**
* Das Passwort dieses **Seitenaufbaus**. Fehlt es, entsteht eines je Aufruf —
* und dann gilt es fuer alle Vorkommen in diesem einen Text. Gespeichert wird
* es nirgends; der Aufrufer haelt es nur so lange, wie er die Seite baut.
*/
passwort?: string,
): ErsetzungsErgebnis {
const unbekannt = new Set<string>();
let mitPasswort = false;
// Erst beim ersten Vorkommen erzeugt: ein Text ohne Passwort erzeugt keines.
let diesesPasswort = passwort;
/*
* Erkannt wird **jede** doppelte Klammer, nicht nur die mit gueltigem
@@ -98,7 +112,8 @@ export function platzhalterErsetzen(
// herum, und das Passwort ist nirgends gespeichert, also auch nicht
// nachvollziehbar, wenn es dort stuende.
if (verwendung.art === "druck") return PASSWORT_IM_DRUCK;
return aufbereiten(passwortErzeugen(), verwendung);
diesesPasswort ??= passwortErzeugen();
return aufbereiten(diesesPasswort, verwendung);
}
const wert = werte[schluessel];
+11
View File
@@ -72,6 +72,17 @@ export function proxy(anfrage: NextRequest): NextResponse {
const pfad = anfrage.nextUrl.pathname;
if (!istOeffentlich(pfad) && !anfrage.cookies.has(SITZUNG_COOKIE)) {
/*
* Eine Abfrage unter `/api/` bekommt **401**, keine Weiterleitung (#013).
* Ein `fetch` folgt einer Weiterleitung von sich aus und bekaeme die
* Anmeldeseite als HTML mit Status 200 zurueck: fuer den Client saehe das
* aus wie eine gueltige Antwort, und die Aktualisierung fragte alle zehn
* Sekunden weiter, statt aufzuhoeren und den Benutzer zur Anmeldung zu
* schicken.
*/
if (pfad.startsWith("/api/")) {
return NextResponse.json({ fehler: "Nicht angemeldet." }, { status: 401 });
}
const ziel = pfad + anfrage.nextUrl.search;
const anmelden = oeffentlicheAdresse("/anmelden", anfrage.nextUrl.origin);
if (ziel !== "/") anmelden.searchParams.set("ziel", ziel);
+421
View File
@@ -0,0 +1,421 @@
"use server";
import { revalidatePath } from "next/cache";
import { SchrittZustand } from "@/generated/prisma/enums";
import { z } from "@/lib/validierung";
import { MandantFehler, type HandelnderBenutzer } from "@/server/mandant";
import type { Recht } from "@/server/auth/rollen";
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import {
LaufFehler,
laufAbschliessen,
laufAnlegen,
laufArchivieren,
laufMitAdKontoVerknuepfen,
laufZurBearbeitungOeffnen,
laufZurueckholen,
} from "@/server/services/laeufe";
import { namenVorschlagen, type NamensVorschlaege } from "@/server/services/befehle";
import { abschnittNichtZutreffend, schrittergebnisSetzen } from "@/server/services/schrittergebnisse";
import { kommentarHinzufuegen } from "@/server/services/kommentare";
/**
* Server Actions der Laeufe (#013).
*
* Duenn: Recht pruefen, Zod anwenden, Dienst rufen. Die fachlichen Regeln — das
* Archiv, die Notizpflicht, die Abschlussbedingung — stehen alle im Dienst, und
* zwar an genau einer Stelle. Eine Action, die eine davon nachbaut, waere eine
* zweite Wahrheit.
*/
export type AktionsErgebnis = { readonly fehler?: string; readonly erfolg?: string };
const RECHTE_MELDUNG =
"Deine Anmeldung gilt nicht mehr. Melde dich in einem zweiten Tab neu an und klicke dann hier " +
"noch einmal — was du eingegeben hast, steht weiterhin auf dieser Seite.";
/**
* Faengt den Rechtefehler ab, statt ihn werfen zu lassen.
*
* Ein ungefangener Wurf landet in Nexts Fehlergrenze und haengt die Seite aus —
* mitten in der Abarbeitung waere das der Verlust dessen, was gerade im Feld
* stand (#010).
*/
async function angemeldetOderMeldung(
recht: Recht,
): Promise<{ readonly benutzer: HandelnderBenutzer } | { readonly fehler: string }> {
try {
const { benutzer } = await rechtErforderlich(recht);
return { benutzer: { id: benutzer.id, rolle: benutzer.rolle } };
} catch (fehler) {
if (fehler instanceof RechteFehler) return { fehler: RECHTE_MELDUNG };
throw fehler;
}
}
function alsErgebnis(fehler: unknown, neutral: string): AktionsErgebnis {
if (fehler instanceof LaufFehler || fehler instanceof MandantFehler) {
return { fehler: fehler.message };
}
console.error(`${neutral}:`, fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : fehler);
return { fehler: neutral };
}
/** Nach jeder Aenderung: Liste und Lauf neu rendern. */
function neuRendern(laufId: string): void {
revalidatePath("/laeufe");
revalidatePath(`/laeufe/${laufId}`);
}
// ---- Anlegen und Kopfdaten ------------------------------------------------
const AnlegenSchema = z.object({
vorlagenVersionId: z.string().min(1, "Bitte eine Vorlage wählen."),
titel: z.string().trim().optional(),
mitarbeiterId: z.string().trim().optional(),
mitarbeiterName: z.string().trim().optional(),
geraetId: z.string().trim().optional(),
abteilungId: z.string().trim().optional(),
ticket: z.string().trim().optional(),
zugewiesenAnId: z.string().trim().optional(),
faelligAm: z.string().trim().optional(),
kundeId: z.string().trim().optional(),
/** Die Variablen kommen als JSON – sie haengen an der gewaehlten Version. */
variablen: z.string().trim().optional(),
});
function variablenLesen(roh: string | undefined): Record<string, string> {
if (!roh) return {};
try {
const geparst: unknown = JSON.parse(roh);
if (typeof geparst !== "object" || geparst === null || Array.isArray(geparst)) return {};
const werte: Record<string, string> = {};
for (const [schluessel, wert] of Object.entries(geparst as Record<string, unknown>)) {
if (typeof wert === "string") werte[schluessel] = wert;
else if (typeof wert === "number" || typeof wert === "boolean") werte[schluessel] = String(wert);
}
return werte;
} catch {
// Ein kaputtes JSON darf den Start nicht verhindern – die Variablen sind
// Beiwerk, der Lauf ist die Hauptsache.
return {};
}
}
function datumLesen(roh: string | undefined): Date | null {
if (!roh) return null;
const datum = new Date(roh);
return Number.isNaN(datum.getTime()) ? null : datum;
}
export async function laufAnlegenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis & { readonly laufId?: string }> {
const zugang = await angemeldetOderMeldung("laeufeBearbeiten");
if ("fehler" in zugang) return zugang;
const eingabe = AnlegenSchema.safeParse(Object.fromEntries(formular.entries()));
if (!eingabe.success) {
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
try {
const lauf = await laufAnlegen(
{
vorlagenVersionId: eingabe.data.vorlagenVersionId,
titel: eingabe.data.titel ?? null,
mitarbeiterId: eingabe.data.mitarbeiterId || null,
mitarbeiterName: eingabe.data.mitarbeiterName || null,
geraetId: eingabe.data.geraetId || null,
abteilungId: eingabe.data.abteilungId || null,
ticket: eingabe.data.ticket || null,
zugewiesenAnId: eingabe.data.zugewiesenAnId || null,
faelligAm: datumLesen(eingabe.data.faelligAm),
kundeId: eingabe.data.kundeId || null,
variablen: variablenLesen(eingabe.data.variablen),
},
zugang.benutzer,
);
revalidatePath("/laeufe");
return { erfolg: `„${lauf.titel}“ wurde angelegt.`, laufId: lauf.id };
} catch (fehler) {
return alsErgebnis(fehler, "Der Lauf konnte nicht angelegt werden.");
}
}
/**
* Vorschlaege fuer `upn`, `sam`, `mail` und `hostname` zu einem Namen (#013).
*
* Beim Onboarding steht die Person noch nicht im Verzeichnis; ihre Anmeldenamen
* entstehen erst hier. Die Regeln und die Eindeutigkeitspruefung liegen im
* Dienst (#012) — diese Action reicht nur durch, damit der Start-Dialog fragen
* kann, ohne die Regeln nachzubauen.
*
* Der Name kommt als ein Feld, weil der Dialog ihn so erfragt: „Jörg
* Müller-Lüdenscheid“. Das erste Wort ist der Vorname, der Rest der Nachname;
* steht dort nur ein Wort, ist es der **Nachname** — `{nachname}` kommt in jeder
* sinnvollen Regel vor, `{vorname}` nicht. Eine Annahme, die fuer die Faelle
* stimmt, fuer die dieser Dialog gebaut ist, und die der Techniker am Vorschlag
* sofort sieht.
*/
export async function namensvorschlaegeAktion(
name: string,
kundeId: string | null,
): Promise<{ readonly vorschlaege?: NamensVorschlaege; readonly fehler?: string }> {
const zugang = await angemeldetOderMeldung("laeufeBearbeiten");
if ("fehler" in zugang) return zugang;
const teile = name.trim().split(/\s+/u).filter(Boolean);
if (teile.length === 0) return { vorschlaege: {} };
const vorname = teile.length > 1 ? teile[0] : null;
const nachname = teile.length > 1 ? teile.slice(1).join(" ") : teile[0];
try {
const vorschlaege = await namenVorschlagen(
kundeId,
{ vorname: vorname ?? null, nachname: nachname ?? null },
zugang.benutzer,
);
return { vorschlaege };
} catch (fehler) {
return alsErgebnis(fehler, "Die Vorschläge ließen sich nicht bilden.");
}
}
/**
* Verknuepft einen Lauf nachtraeglich mit einem Konto aus dem Verzeichnis (#013).
*
* Der Onboarding-Fall: der Lauf beginnt mit einem Freitextnamen, weil es die
* Person im Verzeichnis noch nicht gibt. Sobald das Konto angelegt ist, gehoert
* der Lauf daran — sonst steht die Arbeit fuer immer neben der Person statt an
* ihr, und die Geraete- und Personenhistorie kennt sie nicht.
*/
export async function laufMitAdKontoVerknuepfenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung("laeufeBearbeiten");
if ("fehler" in zugang) return zugang;
const eingabe = z
.object({
laufId: z.string().min(1),
mitarbeiterId: z.string().trim().optional(),
upn: z.string().trim().optional(),
})
.safeParse(Object.fromEntries(formular.entries()));
if (!eingabe.success) {
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
try {
const lauf = await laufMitAdKontoVerknuepfen(
eingabe.data.laufId,
{
mitarbeiterId: eingabe.data.mitarbeiterId || null,
upn: eingabe.data.upn || null,
},
zugang.benutzer,
);
neuRendern(lauf.id);
return { erfolg: "Der Lauf hängt jetzt an dieser Person." };
} catch (fehler) {
return alsErgebnis(fehler, "Die Verknüpfung konnte nicht gespeichert werden.");
}
}
// ---- Abarbeiten -----------------------------------------------------------
/**
* Oeffnet den Lauf zur Bearbeitung.
*
* Erst hier entstehen `gestartetAm` und `IN_ARBEIT` — nicht beim Ansehen. Ein
* `OFFEN`-Lauf ist ein zugewiesener Lauf, und wer ihn nur aufschlaegt, hat noch
* nicht angefangen.
*/
export async function laufOeffnenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung("laeufeBearbeiten");
if ("fehler" in zugang) return zugang;
const laufId = z.string().min(1).safeParse(formular.get("laufId"));
if (!laufId.success) return { fehler: "Es fehlt der Lauf." };
try {
await laufZurBearbeitungOeffnen(laufId.data, zugang.benutzer);
neuRendern(laufId.data);
return { erfolg: "Los geht’s." };
} catch (fehler) {
return alsErgebnis(fehler, "Der Lauf konnte nicht geöffnet werden.");
}
}
const ErgebnisSchema = z.object({
laufId: z.string().min(1),
vorlagenSchrittId: z.string().min(1),
zustand: z.enum(SchrittZustand),
wert: z.string().optional(),
notiz: z.string().optional(),
});
export async function schrittergebnisSetzenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung("laeufeBearbeiten");
if ("fehler" in zugang) return zugang;
const eingabe = ErgebnisSchema.safeParse(Object.fromEntries(formular.entries()));
if (!eingabe.success) {
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
try {
await schrittergebnisSetzen(
{
laufId: eingabe.data.laufId,
vorlagenSchrittId: eingabe.data.vorlagenSchrittId,
zustand: eingabe.data.zustand,
// Fehlt das Feld, bleibt der Wert stehen; ein leeres Feld loescht ihn.
...(eingabe.data.wert === undefined ? {} : { wert: eingabe.data.wert || null }),
...(eingabe.data.notiz === undefined ? {} : { notiz: eingabe.data.notiz || null }),
},
zugang.benutzer,
);
neuRendern(eingabe.data.laufId);
return {};
} catch (fehler) {
return alsErgebnis(fehler, "Der Schritt konnte nicht gespeichert werden.");
}
}
export async function abschnittNichtZutreffendAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung("laeufeBearbeiten");
if ("fehler" in zugang) return zugang;
const eingabe = z
.object({ laufId: z.string().min(1), abschnittId: z.string().min(1), notiz: z.string() })
.safeParse(Object.fromEntries(formular.entries()));
if (!eingabe.success) return { fehler: "Die Eingabe ist unvollständig." };
try {
const ergebnis = await abschnittNichtZutreffend(eingabe.data, zugang.benutzer);
neuRendern(eingabe.data.laufId);
const anzahl = ergebnis.uebersprungen.length;
// Deutsch, auch bei eins: „1 bereits erledigte Schritte“ liest sich wie ein
// Fehler und lenkt von der Meldung ab.
const uebersprungen =
anzahl === 0
? ""
: anzahl === 1
? " 1 bereits erledigter Schritt blieb, wie er war."
: ` ${anzahl} bereits erledigte Schritte blieben, wie sie waren.`;
return {
erfolg: `${ergebnis.gesetzt} ${
ergebnis.gesetzt === 1 ? "Schritt" : "Schritte"
} als nicht zutreffend vermerkt.${uebersprungen}`,
};
} catch (fehler) {
return alsErgebnis(fehler, "Der Abschnitt konnte nicht gesetzt werden.");
}
}
export async function kommentarHinzufuegenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung("laeufeBearbeiten");
if ("fehler" in zugang) return zugang;
const eingabe = z
.object({
laufId: z.string().min(1),
schrittergebnisId: z.string().trim().optional(),
rumpf: z.string(),
})
.safeParse(Object.fromEntries(formular.entries()));
if (!eingabe.success) return { fehler: "Die Eingabe ist unvollständig." };
try {
await kommentarHinzufuegen(
{
laufId: eingabe.data.laufId,
schrittergebnisId: eingabe.data.schrittergebnisId || null,
rumpf: eingabe.data.rumpf,
},
zugang.benutzer,
);
neuRendern(eingabe.data.laufId);
return { erfolg: "Kommentar gespeichert." };
} catch (fehler) {
return alsErgebnis(fehler, "Der Kommentar konnte nicht gespeichert werden.");
}
}
// ---- Abschliessen, Archivieren, Zurueckholen ------------------------------
export async function laufAbschliessenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung("laeufeBearbeiten");
if ("fehler" in zugang) return zugang;
const laufId = z.string().min(1).safeParse(formular.get("laufId"));
if (!laufId.success) return { fehler: "Es fehlt der Lauf." };
try {
await laufAbschliessen(laufId.data, zugang.benutzer);
neuRendern(laufId.data);
return { erfolg: "Lauf abgeschlossen." };
} catch (fehler) {
return alsErgebnis(fehler, "Der Lauf konnte nicht abgeschlossen werden.");
}
}
export async function laufArchivierenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung("laeufeBearbeiten");
if ("fehler" in zugang) return zugang;
const laufId = z.string().min(1).safeParse(formular.get("laufId"));
if (!laufId.success) return { fehler: "Es fehlt der Lauf." };
try {
await laufArchivieren(laufId.data, zugang.benutzer);
neuRendern(laufId.data);
return { erfolg: "Lauf archiviert. Ab jetzt ändert sich daran nichts mehr." };
} catch (fehler) {
return alsErgebnis(fehler, "Der Lauf konnte nicht archiviert werden.");
}
}
/**
* Zurueckholen — nur `ADMIN`.
*
* Das Archiv ist die Zusage, dass ein abgeschlossener Lauf sich nicht mehr
* aendert. Diese Zusage darf nur eine Stelle aufheben; der Dienst prueft das
* Recht noch einmal selbst.
*/
export async function laufZurueckholenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung("archivVerwalten");
if ("fehler" in zugang) return zugang;
const laufId = z.string().min(1).safeParse(formular.get("laufId"));
if (!laufId.success) return { fehler: "Es fehlt der Lauf." };
try {
await laufZurueckholen(laufId.data, zugang.benutzer);
neuRendern(laufId.data);
return { erfolg: "Lauf zurückgeholt — er steht wieder auf „abgeschlossen“." };
} catch (fehler) {
return alsErgebnis(fehler, "Der Lauf konnte nicht zurückgeholt werden.");
}
}
+729
View File
@@ -0,0 +1,729 @@
import type { Prisma } from "@/generated/prisma/client";
import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { platzhalterErsetzen } from "@/lib/platzhalter-werte";
import { platzhalterAusserhalbAnfuehrungszeichen, skriptBauen, wertMaskieren, type Shell } from "@/lib/shell";
import { STANDARD_SHELL } from "@/lib/editor-aufbau";
import { PASSWORT_HINWEIS, passwortErzeugen } from "@/lib/passwort";
import { sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
import { platzhalterWerteDesLaufs } from "@/server/services/platzhalter";
/**
* Lesemodelle der Laeufe (#013).
*
* Wie ueberall wenden sie die Mandantensicht selbst an. Zwei Dinge sind hier
* besonders:
*
* 1. **Ein Lauf zeigt die Version, mit der er gestartet wurde** — nie die
* neueste. Das ist die Kernzusage aus #009, und sie faellt hier an, weil die
* Abarbeitungsansicht die Schritte ueber `Lauf.vorlagenVersionId` liest.
* 2. **Das Zustands-Lesemodell fuers Polling enthaelt keine Befehle.** Ein
* `{{generiertes_passwort}}` entsteht bei **jeder** Aufloesung neu; ein
* Abruf alle zehn Sekunden wuerde dem Techniker das Passwort unter den
* Haenden wegtauschen, waehrend er es gerade abtippt.
*/
async function kundenBedingung(benutzer: HandelnderBenutzer): Promise<{ kundeId: { in: string[] } }[]> {
const sichtbar = await sichtbareKundenIds(benutzer);
return sichtbar === undefined ? [] : [{ kundeId: { in: [...sichtbar] } }];
}
export type LaufZeile = {
readonly id: string;
readonly titel: string;
readonly status: LaufStatus;
readonly vorlagenName: string;
readonly version: number;
readonly geraetName: string | null;
readonly mitarbeiterName: string | null;
/**
* Der Name steht nur als Freitext im Lauf – die Person gab es beim Start im
* Verzeichnis noch nicht. Die Abarbeitungsansicht bietet dann das Verknüpfen
* mit einem AD-Konto an.
*/
readonly nurFreitextName: boolean;
readonly ticket: string | null;
readonly faelligAm: Date | null;
readonly zugewiesenAn: string | null;
readonly kundeName: string;
readonly erstelltAm: Date;
readonly abgeschlossenAm: Date | null;
readonly anzahlSchritte: number;
readonly anzahlErledigt: number;
};
export type LaufFilter = {
readonly suche?: string;
readonly status?: LaufStatus;
/** Nur Laeufe, die dieser Person zugewiesen sind. */
readonly zugewiesenAnId?: string;
/** Standard: ohne archivierte – die stehen im Archiv (#015). */
readonly auchArchivierte?: boolean;
};
/** So viele Laeufe zeigt die Liste hoechstens; darueber hinaus wird gefiltert. */
export const LISTE_HOECHSTENS = 200;
const ZEILEN_AUSWAHL = {
id: true,
titel: true,
status: true,
ticket: true,
faelligAm: true,
erstelltAm: true,
abgeschlossenAm: true,
mitarbeiterName: true,
kunde: { select: { name: true } },
zugewiesenAn: { select: { name: true } },
mitarbeiter: { select: { anzeigename: true } },
geraet: { select: { hostname: true, bezeichnung: true, tagCode: true } },
vorlagenVersion: { select: { version: true, vorlage: { select: { name: true } } } },
ergebnisse: { select: { zustand: true } },
} satisfies Prisma.LaufSelect;
type Rohzeile = Prisma.LaufGetPayload<{ select: typeof ZEILEN_AUSWAHL }>;
/** Noch nichts geschehen: dann sagt auch der Zeitstempel der Zeile nichts. */
function istOffen(zustand: SchrittZustand): boolean {
return zustand === SchrittZustand.OFFEN;
}
/** Erledigt heisst hier: der Schritt ist vom Tisch – auch als „nicht zutreffend“. */
function istVomTisch(zustand: SchrittZustand): boolean {
return zustand === SchrittZustand.ERLEDIGT || zustand === SchrittZustand.NICHT_ZUTREFFEND;
}
function zeileBauen(zeile: Rohzeile): LaufZeile {
return {
id: zeile.id,
titel: zeile.titel,
status: zeile.status,
vorlagenName: zeile.vorlagenVersion.vorlage.name,
version: zeile.vorlagenVersion.version,
geraetName: zeile.geraet?.hostname ?? zeile.geraet?.bezeichnung ?? zeile.geraet?.tagCode ?? null,
// Freitext nur, wenn keine Person aus dem Verzeichnis dranhaengt – die
// Datenbank schliesst beides zugleich aus.
mitarbeiterName: zeile.mitarbeiter?.anzeigename ?? zeile.mitarbeiterName,
nurFreitextName: zeile.mitarbeiter === null && zeile.mitarbeiterName !== null,
ticket: zeile.ticket,
faelligAm: zeile.faelligAm,
zugewiesenAn: zeile.zugewiesenAn?.name ?? null,
kundeName: zeile.kunde.name,
erstelltAm: zeile.erstelltAm,
abgeschlossenAm: zeile.abgeschlossenAm,
anzahlSchritte: zeile.ergebnisse.length,
anzahlErledigt: zeile.ergebnisse.filter((ergebnis) => istVomTisch(ergebnis.zustand)).length,
};
}
export async function laufListe(
benutzer: HandelnderBenutzer,
filter: LaufFilter = {},
): Promise<readonly LaufZeile[]> {
const suche = filter.suche?.trim();
const bedingungen = [
...(await kundenBedingung(benutzer)),
filter.auchArchivierte ? undefined : { status: { not: LaufStatus.ARCHIVIERT } },
filter.status ? { status: filter.status } : undefined,
filter.zugewiesenAnId ? { zugewiesenAnId: filter.zugewiesenAnId } : undefined,
// Gesucht wird ueber das, was auf einem Zettel stuende: Titel, Ticket,
// Hostname und Name der Person.
suche
? {
OR: [
{ titel: { contains: suche, mode: "insensitive" as const } },
{ ticket: { contains: suche, mode: "insensitive" as const } },
{ mitarbeiterName: { contains: suche, mode: "insensitive" as const } },
{ geraet: { hostname: { contains: suche, mode: "insensitive" as const } } },
{ geraet: { tagCode: { contains: suche, mode: "insensitive" as const } } },
{ mitarbeiter: { anzeigename: { contains: suche, mode: "insensitive" as const } } },
],
}
: undefined,
].filter((bedingung) => bedingung !== undefined);
const zeilen = await prisma.lauf.findMany({
where: { AND: bedingungen },
orderBy: [{ faelligAm: { sort: "asc", nulls: "last" } }, { erstelltAm: "desc" }],
/*
* Gekappt, weil die Liste die **laufende Arbeit** zeigt: archivierte stehen
* hier ohnehin nicht, und wer 200 offene Laeufe hat, sucht oder filtert.
* Ohne Grenze baute ein Haus nach zwei Jahren eine Seite mit tausenden
* Karten — jede mit ihren Schrittergebnissen.
*/
take: LISTE_HOECHSTENS,
select: ZEILEN_AUSWAHL,
});
return zeilen.map(zeileBauen);
}
/** `undefined` heisst „gibt es nicht oder darfst du nicht sehen“. */
export async function laufHolen(
laufId: string,
benutzer: HandelnderBenutzer,
): Promise<LaufZeile | undefined> {
const zeile = await prisma.lauf.findFirst({
where: { AND: [{ id: laufId }, ...(await kundenBedingung(benutzer))] },
select: ZEILEN_AUSWAHL,
});
return zeile ? zeileBauen(zeile) : undefined;
}
export type AbarbeitungsSchritt = {
readonly schrittId: string;
readonly ergebnisId: string | null;
readonly nummer: number;
readonly titel: string;
readonly hilfetext: string | null;
readonly typ: SchrittTyp;
readonly pflicht: boolean;
readonly dokuUrl: string | null;
readonly konfig: Record<string, unknown>;
readonly zustand: SchrittZustand;
readonly wert: unknown;
readonly notiz: string | null;
readonly bearbeitetVon: string | null;
readonly bearbeitetAm: Date | null;
readonly anzahlKommentare: number;
/** Nur bei `BEFEHL`: der fertige Befehl mit eingesetzten Werten. */
readonly befehl: FertigerSchrittBefehl | null;
};
export type FertigerSchrittBefehl = {
readonly shell: Shell;
readonly text: string;
/** Platzhalter, die niemand kennt – sie stehen noch als `{{name}}` darin. */
readonly unbekannt: readonly string[];
readonly mitPasswort: boolean;
readonly hinweis: string | null;
/** Gesetzt, wenn sich der Befehl nicht sicher fuellen liess. */
readonly fehler: string | null;
};
export type AbarbeitungsAbschnitt = {
/** Die Kennung des `VorlagenAbschnitt`s – fuer „Abschnitt trifft nicht zu“. */
readonly id: string;
readonly titel: string;
readonly nummer: string;
readonly schritte: readonly AbarbeitungsSchritt[];
/** Alle Befehle des Abschnitts als ein Skript – leer, wenn es keine gibt. */
readonly skript: string;
};
export type LaufZumAbarbeiten = {
readonly lauf: LaufZeile;
readonly abschnitte: readonly AbarbeitungsAbschnitt[];
readonly anzahlPflicht: number;
readonly anzahlPflichtOffen: number;
};
/**
* Der ganze Lauf zum Abarbeiten: Abschnitte, Schritte, Ergebnisse und die
* fertigen Befehle.
*
* Gelesen wird ueber `Lauf.vorlagenVersionId` — die Version, mit der der Lauf
* gestartet wurde. Ein spaeteres Veroeffentlichen aendert hier nichts (#009).
*/
export async function laufZumAbarbeiten(
laufId: string,
benutzer: HandelnderBenutzer,
technikerName?: string | null,
): Promise<LaufZumAbarbeiten | undefined> {
const kopf = await laufHolen(laufId, benutzer);
if (!kopf) return undefined;
const lauf = await prisma.lauf.findUniqueOrThrow({
where: { id: laufId },
select: {
vorlagenVersion: {
select: {
abschnitte: {
orderBy: { position: "asc" },
select: {
id: true,
titel: true,
schritte: {
orderBy: { position: "asc" },
select: {
id: true,
titel: true,
hilfetext: true,
typ: true,
pflicht: true,
dokuUrl: true,
konfig: true,
},
},
},
},
},
},
ergebnisse: {
select: {
id: true,
vorlagenSchrittId: true,
zustand: true,
wert: true,
notiz: true,
aktualisiertAm: true,
aktualisiertVon: { select: { name: true } },
_count: { select: { kommentare: true } },
},
},
},
});
const werte = await platzhalterWerteDesLaufs({
laufId,
...(technikerName === undefined ? {} : { technikerName }),
});
const ergebnisse = new Map(lauf.ergebnisse.map((ergebnis) => [ergebnis.vorlagenSchrittId, ergebnis]));
/*
* **Ein** Passwort fuer diesen Seitenaufbau (Reviewbefund zu #013). Vorher
* bekam jedes Vorkommen ein eigenes: der Befehl, der das Konto anlegt, und die
* Zeile, die das Passwort mitteilt, trugen verschiedene — das Konto bekam A,
* der Benutzer erfuhr B, und weil nichts gespeichert wird, war A danach weg.
* Gespeichert wird es auch jetzt nirgends; beim naechsten Aufbau ist es ein
* anderes, und genau darum darf die Seite sich nicht von selbst neu bauen,
* solange eines darauf steht (siehe `LaufAktualisierung`).
*/
const passwort = passwortErzeugen();
let nummer = 0;
let anzahlPflicht = 0;
let anzahlPflichtOffen = 0;
const abschnitte = lauf.vorlagenVersion.abschnitte.map((abschnitt, index) => {
const befehle: { titel: string; shell: Shell; befehl: string }[] = [];
const schritte = abschnitt.schritte.map((schritt) => {
nummer += 1;
const ergebnis = ergebnisse.get(schritt.id);
const zustand = ergebnis?.zustand ?? SchrittZustand.OFFEN;
if (schritt.pflicht) {
anzahlPflicht += 1;
if (!istVomTisch(zustand)) anzahlPflichtOffen += 1;
}
const konfig = (schritt.konfig ?? {}) as Record<string, unknown>;
const befehl = schritt.typ === SchrittTyp.BEFEHL ? befehlFuellen(konfig, werte, passwort) : null;
if (befehl && befehl.fehler === null) {
befehle.push({ titel: schritt.titel, shell: befehl.shell, befehl: befehl.text });
}
return {
schrittId: schritt.id,
ergebnisId: ergebnis?.id ?? null,
nummer,
titel: platzhalterErsetzen(schritt.titel, werte).text,
hilfetext: schritt.hilfetext ? platzhalterErsetzen(schritt.hilfetext, werte).text : null,
typ: schritt.typ,
pflicht: schritt.pflicht,
dokuUrl: schritt.dokuUrl,
konfig,
zustand,
wert: ergebnis?.wert ?? null,
notiz: ergebnis?.notiz ?? null,
/*
* „Wer wann“, sobald der Schritt nicht mehr offen ist — nicht erst, wenn
* ein **Mensch** ihn angefasst hat. Die Ergebniszeilen entstehen beim
* Anlegen des Laufs, `aktualisiertAm` traegt also auch an einem
* unberuehrten Schritt einen Zeitstempel; der sagt nichts, deshalb der
* Zustand als Bedingung.
*
* Am Zustand und nicht am Urheber, weil `aktualisiertVonId` leer bleiben
* **darf**: ein Schritt vom Typ `AKTION` (Phase 4) erledigt sich selbst,
* und ausgerechnet dann fiele das „wann“ weg (CLAUDE.md,
* „Erweiterbarkeit“; Reviewbefund zu #013).
*/
bearbeitetVon: ergebnis?.aktualisiertVon?.name ?? (istOffen(zustand) ? null : "System"),
bearbeitetAm: istOffen(zustand) ? null : (ergebnis?.aktualisiertAm ?? null),
anzahlKommentare: ergebnis?._count.kommentare ?? 0,
befehl,
};
});
return {
id: abschnitt.id,
titel: abschnitt.titel,
nummer: String(index + 1).padStart(2, "0"),
schritte,
skript: skriptBauenAus(befehle),
};
});
return { lauf: kopf, abschnitte, anzahlPflicht, anzahlPflichtOffen };
}
/**
* Fuellt einen einzelnen Befehl.
*
* Bewusst hier und nicht ueber `befehleDesLaufs`: das Lesemodell braucht den
* Befehl **am Schritt**, und es darf ihn nur **einmal** fuellen — bei jedem
* Fuellen entsteht ein neues `{{generiertes_passwort}}`. Zweimal gefuellt hiesse
* zwei verschiedene Passwoerter in einer Antwort (Reviewbefund aus #012).
*/
function befehlFuellen(
konfig: Record<string, unknown>,
werte: Readonly<Record<string, string>>,
/** Das Passwort dieses Seitenaufbaus – dasselbe fuer jeden Befehl der Seite. */
passwort: string,
): FertigerSchrittBefehl {
const shell = shellAus(konfig);
const roh = typeof konfig["befehl"] === "string" ? konfig["befehl"] : "";
const nackt = platzhalterAusserhalbAnfuehrungszeichen(roh, shell);
if (nackt.length > 0) {
const zeichen = shell === "bash" ? "einfache" : "doppelte";
return {
shell,
text: roh,
unbekannt: [],
mitPasswort: false,
hinweis: null,
fehler:
`${nackt.map((name) => `„{{${name}}}“`).join(", ")} steht nicht in Anführungszeichen. ` +
`Der Wert lässt sich dann nicht sicher einsetzen — bitte ${zeichen} Anführungszeichen ` +
"um den Platzhalter setzen.",
};
}
try {
const ergebnis = platzhalterErsetzen(
roh,
werte,
{ art: "befehl", maskieren: (wert) => wertMaskieren(wert, shell) },
passwort,
);
return {
shell,
text: ergebnis.text,
unbekannt: ergebnis.unbekannt,
mitPasswort: ergebnis.mitPasswort,
hinweis: ergebnis.mitPasswort ? PASSWORT_HINWEIS : null,
fehler: null,
};
} catch (fehler) {
// `wertMaskieren` wirft fuer cmd, wenn ein Wert nicht sicher darstellbar
// ist. Der Befehl bleibt mit Platzhaltern stehen: sichtbar, nicht kopierbar.
return {
shell,
text: roh,
unbekannt: [],
mitPasswort: false,
hinweis: null,
fehler: fehler instanceof Error ? fehler.message : "Der Befehl ließ sich nicht füllen.",
};
}
}
function shellAus(konfig: Record<string, unknown>): Shell {
const wert = konfig["shell"];
return wert === "powershell" || wert === "bash" || wert === "cmd" ? wert : STANDARD_SHELL;
}
/** Das Skript eines Abschnitts – aus den **bereits gefuellten** Befehlen. */
function skriptBauenAus(befehle: readonly { titel: string; shell: Shell; befehl: string }[]): string {
if (befehle.length === 0) return "";
// `skriptBauen` liegt in src/lib/shell.ts und erwartet genau diese Form.
return skriptBauen(befehle);
}
export type LaufZustand = {
readonly status: LaufStatus;
readonly anzahlPflichtOffen: number;
/**
* Zahl der Kommentare und Zeitpunkt der letzten Aenderung an den Kopfdaten.
*
* Beides gehoert dazu, weil „zwei Browser sehen die Arbeit des anderen“ sonst
* nur fuer Schrittzustaende gilt: ein Kommentar des Kollegen und ein
* geaenderter Titel blieben unsichtbar, bis jemand von sich aus neu laedt
* (Reviewbefund zu #013).
*/
readonly anzahlKommentare: number;
readonly geaendertAm: Date;
readonly schritte: readonly {
readonly schrittId: string;
readonly zustand: SchrittZustand;
readonly bearbeitetVon: string | null;
readonly bearbeitetAm: Date | null;
}[];
};
/**
* Der schmale Zustand fuer das Polling alle zehn Sekunden.
*
* **Ohne Befehle, und das ist der Punkt.** Ein `{{generiertes_passwort}}`
* entsteht bei jeder Aufloesung neu — wuerde das Polling die Befehle
* mitliefern, tauschte es dem Techniker das Passwort unter den Haenden weg,
* waehrend er es abtippt.
*
* Mitgeliefert wird dagegen alles, was eine Aenderung **anzeigt**: Status,
* offene Pflichtschritte, Schrittzustaende, die Zahl der Kommentare und
* `geaendertAm` des Laufs. Der Client vergleicht die Antwort als Ganzes mit der
* vorigen; was hier fehlt, bleibt im anderen Browser unsichtbar.
*/
export async function laufZustand(
laufId: string,
benutzer: HandelnderBenutzer,
): Promise<LaufZustand | undefined> {
const lauf = await prisma.lauf.findFirst({
where: { AND: [{ id: laufId }, ...(await kundenBedingung(benutzer))] },
select: {
status: true,
geaendertAm: true,
_count: { select: { kommentare: true } },
ergebnisse: {
select: {
vorlagenSchrittId: true,
zustand: true,
aktualisiertAm: true,
aktualisiertVon: { select: { name: true } },
vorlagenSchritt: { select: { pflicht: true } },
},
},
},
});
if (!lauf) return undefined;
return {
status: lauf.status,
anzahlKommentare: lauf._count.kommentare,
geaendertAm: lauf.geaendertAm,
anzahlPflichtOffen: lauf.ergebnisse.filter(
(ergebnis) => ergebnis.vorlagenSchritt.pflicht && !istVomTisch(ergebnis.zustand),
).length,
schritte: lauf.ergebnisse.map((ergebnis) => ({
schrittId: ergebnis.vorlagenSchrittId,
zustand: ergebnis.zustand,
// Siehe `laufZumAbarbeiten`: an einem offenen Schritt ist der Zeitstempel
// nur der Anlegezeitpunkt der Zeile und sagt nichts.
bearbeitetVon: ergebnis.aktualisiertVon?.name ?? (istOffen(ergebnis.zustand) ? null : "System"),
bearbeitetAm: istOffen(ergebnis.zustand) ? null : ergebnis.aktualisiertAm,
})),
};
}
export type KommentarZeile = {
readonly id: string;
readonly rumpf: string;
readonly benutzer: string;
readonly erstelltAm: Date;
/** Leer: der Kommentar haengt am Lauf und nicht an einem Schritt. */
readonly schrittergebnisId: string | null;
};
/**
* Die Kommentare eines Laufs, aelteste zuerst.
*
* Alle auf einmal statt je Schritt: ein Lauf hat selten mehr als eine Handvoll,
* und eine Abfrage je Schritt waeren bei 150 Schritten 150 Abfragen.
*/
export async function kommentareDesLaufs(
laufId: string,
benutzer: HandelnderBenutzer,
): Promise<readonly KommentarZeile[]> {
const lauf = await prisma.lauf.findFirst({
where: { AND: [{ id: laufId }, ...(await kundenBedingung(benutzer))] },
select: { id: true },
});
if (!lauf) return [];
const zeilen = await prisma.kommentar.findMany({
where: { laufId },
orderBy: { erstelltAm: "asc" },
select: {
id: true,
rumpf: true,
erstelltAm: true,
schrittergebnisId: true,
benutzer: { select: { name: true } },
},
});
return zeilen.map((zeile) => ({
id: zeile.id,
rumpf: zeile.rumpf,
benutzer: zeile.benutzer.name,
erstelltAm: zeile.erstelltAm,
schrittergebnisId: zeile.schrittergebnisId,
}));
}
/**
* Die Personen, mit denen sich dieser Lauf verknuepfen laesst.
*
* Nur Personen **desselben Kunden**: der Dienst weist alles andere ohnehin ab,
* und eine Liste, die fremde Namen zeigt, verraet sie trotzdem. Gekappt bei 500,
* wie die Listen im Start-Dialog; wer die Person dort nicht findet, traegt den
* UPN ein.
*/
export async function personenZurVerknuepfung(
laufId: string,
benutzer: HandelnderBenutzer,
): Promise<readonly { id: string; anzeigename: string }[]> {
const lauf = await prisma.lauf.findFirst({
where: { AND: [{ id: laufId }, ...(await kundenBedingung(benutzer))] },
select: { kundeId: true },
});
if (!lauf) return [];
return prisma.mitarbeiter.findMany({
where: { kundeId: lauf.kundeId, aktiv: true },
orderBy: { anzeigename: "asc" },
take: 500,
select: { id: true, anzeigename: true },
});
}
export type StartbareVorlage = {
readonly versionId: string;
readonly vorlageId: string;
readonly name: string;
readonly version: number;
readonly kategorie: string | null;
readonly variablen: readonly {
readonly schluessel: string;
readonly beschriftung: string;
readonly typ: string;
readonly optionen: readonly string[];
readonly pflicht: boolean;
}[];
};
/**
* Die Vorlagen, an denen sich ein Lauf starten laesst.
*
* Genau die **zuletzt veroeffentlichte** Version je Vorlage — nicht alle. Wer
* einen Lauf startet, will die geltende Fassung; eine Liste mit allen Versionen
* waere eine Wahl, die niemand treffen will und bei der man sich vertun kann.
* Archivierte Vorlagen stehen nicht dabei.
*/
export async function startbareVorlagen(benutzer: HandelnderBenutzer): Promise<readonly StartbareVorlage[]> {
const sichtbar = await sichtbareKundenIds(benutzer);
const vorlagen = await prisma.vorlage.findMany({
where: {
AND: [
{ archiviert: false },
// Vorlagen ohne Kundenbezug gelten fuer alle (#009).
...(sichtbar === undefined ? [] : [{ OR: [{ kundeId: null }, { kundeId: { in: [...sichtbar] } }] }]),
],
},
orderBy: [{ kategorie: "asc" }, { name: "asc" }],
select: {
id: true,
name: true,
kategorie: true,
versionen: {
where: { status: "VEROEFFENTLICHT" },
orderBy: { version: "desc" },
take: 1,
select: {
id: true,
version: true,
variablen: {
orderBy: { position: "asc" },
select: {
schluessel: true,
beschriftung: true,
typ: true,
optionen: true,
pflicht: true,
},
},
},
},
},
});
const startbare: StartbareVorlage[] = [];
for (const vorlage of vorlagen) {
const version = vorlage.versionen[0];
// Eine Vorlage ohne veroeffentlichte Version laesst sich nicht starten – ein
// Entwurf ist noch niemandes Arbeitsanweisung.
if (!version) continue;
startbare.push({
versionId: version.id,
vorlageId: vorlage.id,
name: vorlage.name,
version: version.version,
kategorie: vorlage.kategorie,
variablen: version.variablen,
});
}
return startbare;
}
export type StartAuswahl = {
readonly personen: readonly { id: string; anzeigename: string }[];
readonly geraete: readonly { id: string; anzeige: string; besitzerId: string | null }[];
readonly abteilungen: readonly { id: string; name: string }[];
readonly bearbeiter: readonly { id: string; name: string }[];
};
/**
* Was im Start-Dialog zur Auswahl steht.
*
* Gekappt bei 500 je Liste — ein Auswahlfeld mit zehntausend Eintraegen ist
* keine Auswahl. **Der Dialog sagt das auch**, wenn eine Liste voll ist; die
* Suche ueber alle kommt mit der Geraeteseite (#014). Ohne den Hinweis fehlte
* das 501. Geraet stillschweigend, und niemand wuesste, wo es geblieben ist.
*/
export async function startAuswahl(benutzer: HandelnderBenutzer): Promise<StartAuswahl> {
const mandant = await kundenBedingung(benutzer);
const [personen, geraete, abteilungen, bearbeiter] = await Promise.all([
prisma.mitarbeiter.findMany({
where: { AND: [{ aktiv: true }, ...mandant] },
orderBy: { anzeigename: "asc" },
take: 500,
select: { id: true, anzeigename: true },
}),
prisma.geraet.findMany({
where: { AND: [{ archiviert: false }, ...mandant] },
orderBy: [{ hostname: "asc" }, { bezeichnung: "asc" }],
take: 500,
select: { id: true, hostname: true, bezeichnung: true, tagCode: true, mitarbeiterId: true },
}),
prisma.abteilung.findMany({
where: { AND: [{ archiviert: false }, ...mandant] },
orderBy: { name: "asc" },
take: 500,
select: { id: true, name: true },
}),
/*
* Zuweisen laesst sich nur an jemanden, der den Lauf auch oeffnen koennte
* (Reviewbefund zu #013). Vorher stand hier jeder Bearbeiter des Hauses:
* eine Technikerin von Kunde B konnte einen Lauf an den Techniker von Kunde
* A zuweisen, der ihn dann nicht einmal sah — und der Dienst lehnte es beim
* Anlegen mit einer Meldung ab, die niemand erwartet hatte.
*
* Wer an **keinen** Kunden gebunden ist, sieht alle und steht deshalb immer
* zur Auswahl; die Rollen `ADMIN` und `REDAKTEUR` sind ohnehin nicht
* gebunden (siehe `sichtbareKundenIds`).
*/
prisma.benutzer.findMany({
where: {
aktiv: true,
rolle: { in: ["ADMIN", "REDAKTEUR", "TECHNIKER"] },
...(mandant.length === 0
? {}
: { OR: [{ kundenbindungen: { none: {} } }, { kundenbindungen: { some: mandant[0] } }] }),
},
orderBy: { name: "asc" },
take: 500,
select: { id: true, name: true },
}),
]);
return {
personen,
geraete: geraete.map((geraet) => ({
id: geraet.id,
anzeige: `${geraet.hostname ?? geraet.bezeichnung ?? geraet.tagCode} · ${geraet.tagCode}`,
besitzerId: geraet.mitarbeiterId,
})),
abteilungen,
bearbeiter,
};
}
+31 -11
View File
@@ -7,7 +7,7 @@ import {
type Namenswerte,
type Zielformat,
} from "@/lib/namensregeln";
import { PASSWORT_HINWEIS } from "@/lib/passwort";
import { PASSWORT_HINWEIS, passwortErzeugen } from "@/lib/passwort";
import { platzhalterErsetzen, type ErsetzungsErgebnis } from "@/lib/platzhalter-werte";
import {
platzhalterAusserhalbAnfuehrungszeichen,
@@ -71,7 +71,16 @@ export type FertigerBefehl = {
* Der Titel wird **ohne** Shell-Maskierung ersetzt: er wird gelesen, nicht
* ausgefuehrt. Ein maskierter Titel stuende mit Backticks auf dem Bildschirm.
*/
export function befehlFuellen(schritt: BefehlsSchritt, werte: PlatzhalterWerte): FertigerBefehl {
export function befehlFuellen(
schritt: BefehlsSchritt,
werte: PlatzhalterWerte,
/**
* Das Passwort dieses Durchgangs. Ohne Angabe bekommt jeder Befehl sein
* eigenes — richtig fuer einen einzelnen, falsch fuer einen Lauf, in dem ein
* Schritt das Konto anlegt und der naechste das Passwort uebergibt.
*/
passwort?: string,
): FertigerBefehl {
const titel = platzhalterErsetzen(schritt.titel, werte).text;
/*
@@ -97,10 +106,12 @@ export function befehlFuellen(schritt: BefehlsSchritt, werte: PlatzhalterWerte):
}
try {
const befehl = platzhalterErsetzen(schritt.befehl, werte, {
art: "befehl",
maskieren: (wert) => wertMaskieren(wert, schritt.shell),
});
const befehl = platzhalterErsetzen(
schritt.befehl,
werte,
{ art: "befehl", maskieren: (wert) => wertMaskieren(wert, schritt.shell) },
passwort,
);
return {
titel,
shell: schritt.shell,
@@ -139,9 +150,10 @@ export function befehlFuellen(schritt: BefehlsSchritt, werte: PlatzhalterWerte):
/**
* Alle Befehle eines Abschnitts als ein Skript zum Kopieren.
*
* **Die Passwoerter darin entstehen genau jetzt.** Wer das Skript zweimal
* kopiert, bekommt zweimal verschiedene — das ist die ehrliche Folge der Regel
* „bei jeder Anzeige neu“ und der Grund, warum der Hinweis daneben steht.
* **Das Passwort darin entsteht genau jetzt.** Wer die Seite neu laedt, bekommt
* ein anderes — das ist die ehrliche Folge der Regel „bei jeder Anzeige neu“ und
* der Grund, warum der Hinweis daneben steht. Innerhalb eines Aufbaus ist es
* ueberall dasselbe.
*/
/**
* Ein Skript aus **bereits gefuellten** Schritten.
@@ -184,6 +196,7 @@ export function skriptAusFertigen(fertige: readonly FertigerBefehl[]): {
export function skriptFuerAbschnitt(
schritte: readonly BefehlsSchritt[],
werte: PlatzhalterWerte,
passwort?: string,
): {
readonly skript: string;
readonly mitPasswort: boolean;
@@ -191,7 +204,7 @@ export function skriptFuerAbschnitt(
/** Schritte, deren Werte sich nicht sicher maskieren liessen. */
readonly nichtMaskierbar: readonly string[];
} {
return skriptAusFertigen(schritte.map((schritt) => befehlFuellen(schritt, werte)));
return skriptAusFertigen(schritte.map((schritt) => befehlFuellen(schritt, werte, passwort)));
}
/**
@@ -234,6 +247,13 @@ export async function befehleDesLaufs(kontext: LaufKontext): Promise<
if (!lauf) return [];
const werte = await platzhalterWerteDesLaufs(kontext);
/*
* **Ein** Passwort fuer den ganzen Lauf, nicht eines je Befehl: der Schritt
* „Konto anlegen“ und der Schritt „Passwort uebergeben“ meinen dasselbe.
* Erzeugt wird es hier und nirgends gespeichert; beim naechsten Abruf ist es
* ein anderes.
*/
const passwort = passwortErzeugen();
return lauf.vorlagenVersion.abschnitte
.map((abschnitt) => {
@@ -244,7 +264,7 @@ export async function befehleDesLaufs(kontext: LaufKontext): Promise<
}));
// **Einmal** fuellen und daraus beides bauen: ein zweiter Durchgang
// erzeugte ein zweites Passwort (siehe skriptAusFertigen).
const gefuellt = schritte.map((schritt) => befehlFuellen(schritt, werte));
const gefuellt = schritte.map((schritt) => befehlFuellen(schritt, werte, passwort));
const skript = skriptAusFertigen(gefuellt);
return {
abschnitt: abschnitt.titel,
+80
View File
@@ -0,0 +1,80 @@
import type { Kommentar } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import type { HandelnderBenutzer } from "@/server/mandant";
import { LaufFehler, laufZumSchreibenHolen } from "@/server/services/laeufe";
/**
* Kommentare an Laeufen und Schrittergebnissen (#013).
*
* Der Ort fuer das, was in kein Feld passt: „Kunde war nicht erreichbar“,
* „Lizenz kommt am Montag“, „Netzteil defekt, Ersatz bestellt“. Ein Kommentar
* traegt seinen Urheber und seinen Zeitpunkt selbst, und er wird nie geaendert —
* deshalb gibt es hier nur einen Weg hinein und keinen hinaus. Das ist auch der
* Grund, warum ein Kommentar **keinen** Protokolleintrag bekommt: er ist schon
* einer. Ein zweiter Eintrag, der dasselbe noch einmal sagt, wuerde das Protokoll
* nur laenger machen.
*
* Der Schreibschutz gilt trotzdem: an einem archivierten Lauf kommt kein
* Kommentar mehr dazu. Der Weg dorthin ist derselbe wie bei den
* Schrittergebnissen — `laufZumSchreibenHolen`, und darin
* `laufMussVeraenderbarSein`.
*/
/**
* Obergrenze je Kommentar.
*
* Ohne Grenze landet ein versehentlich eingefuegtes Ereignisprotokoll in der
* Datenbank und in jeder Druckansicht. Viertausend Zeichen sind ungefaehr zwei
* DIN-A4-Seiten — mehr als genug fuer eine Notiz, und die Meldung nennt die Zahl.
*/
export const KOMMENTAR_HOECHSTLAENGE = 4000;
export type NeuerKommentar = {
readonly laufId: string;
/** Leer: der Kommentar haengt am Lauf und nicht an einem einzelnen Schritt. */
readonly schrittergebnisId?: string | null;
readonly rumpf: string;
};
/**
* Schreibt einen Kommentar.
*
* Ein Kommentar **startet den Lauf nicht**: eine Rueckfrage zu notieren ist keine
* Abarbeitung, und ein zugewiesener Lauf, der von einer Notiz auf `IN_ARBEIT`
* springt, saehe in der Liste aus wie begonnene Arbeit.
*/
export async function kommentarHinzufuegen(
eingabe: NeuerKommentar,
benutzer: HandelnderBenutzer,
): Promise<Kommentar> {
const rumpf = eingabe.rumpf.trim();
if (!rumpf) throw new LaufFehler("Ein leerer Kommentar hilft niemandem.");
if (rumpf.length > KOMMENTAR_HOECHSTLAENGE) {
throw new LaufFehler(
`Der Kommentar ist ${rumpf.length} Zeichen lang; erlaubt sind ${KOMMENTAR_HOECHSTLAENGE}. ` +
"Längeres gehört in die Notiz am Schritt oder ins Ticket.",
);
}
return prisma.$transaction(async (tx) => {
const lauf = await laufZumSchreibenHolen(tx, eingabe.laufId, benutzer);
const schrittergebnisId = eingabe.schrittergebnisId?.trim() || null;
if (schrittergebnisId) {
// Die Kennung kommt von aussen. Ohne diese Pruefung hinge der Kommentar an
// einem Schritt eines fremden Laufs – und damit moeglicherweise eines
// fremden Kunden, dessen Schreibschutz nie geprueft wurde.
const ergebnis = await tx.schrittergebnis.findUnique({
where: { id: schrittergebnisId },
select: { laufId: true },
});
if (!ergebnis || ergebnis.laufId !== lauf.id) {
throw new LaufFehler("Dieser Schritt gehört nicht zu diesem Lauf.");
}
}
return tx.kommentar.create({
data: { laufId: lauf.id, schrittergebnisId, benutzerId: benutzer.id, rumpf },
});
});
}
File diff suppressed because it is too large Load Diff
+314
View File
@@ -0,0 +1,314 @@
import { Prisma, type Schrittergebnis } from "@/generated/prisma/client";
import { SchrittZustand } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import type { HandelnderBenutzer } from "@/server/mandant";
import {
LaufFehler,
abschlussPruefenNachAenderung,
laufStartenFallsOffen,
laufZumSchreibenHolen,
} from "@/server/services/laeufe";
/**
* Schrittergebnisse (#013).
*
* Ein Schritt wird **einzeln** gesetzt, nicht als Gesamtformular: der Techniker
* arbeitet einen Laufzettel von oben nach unten ab, oft am Handy, oft
* unterbrochen. Ein Formular, das am Ende alles auf einmal schickt, verliert
* genau dann die Arbeit, wenn es darauf ankommt.
*
* Drei Regeln stehen hier:
*
* 1. **`NICHT_ZUTREFFEND` und `FEHLGESCHLAGEN` verlangen eine Notiz.** Sonst
* stuende im Archiv ein uebersprungener Schritt ohne Begruendung — und genau
* das ist die Luecke, die ein Archiv wertlos macht. Die Bedingung
* `Schrittergebnis_begruendung_noetig` haelt dasselbe in der Datenbank fest.
* 2. **Jede Aenderung bekommt einen Protokolleintrag**, in derselben
* Transaktion wie die Aenderung selbst.
* 3. **Der Schreibschutz des Archivs laeuft durch `laufZumSchreibenHolen`** —
* die eine Stelle, die `laufMussVeraenderbarSein` ruft und die Laufzeile
* sperrt.
*/
/** Wie ein Zustand im Protokoll heisst. Die Oberflaeche hat ihre eigenen Texte. */
const ZUSTAND_AKTION: Readonly<Record<SchrittZustand, string>> = {
OFFEN: "Schritt zurückgesetzt",
ERLEDIGT: "Schritt erledigt",
NICHT_ZUTREFFEND: "Schritt als nicht zutreffend eingetragen",
FEHLGESCHLAGEN: "Schritt als fehlgeschlagen eingetragen",
};
export type SchrittergebnisEingabe = {
readonly laufId: string;
readonly vorlagenSchrittId: string;
readonly zustand: SchrittZustand;
/**
* Der erfasste Wert. Fehlt das Feld, bleibt der vorhandene Wert stehen;
* `null` loescht ihn. Ein Haken, der einen Text mitbringt, und ein Zustand, der
* ohne Wert wechselt, sind verschiedene Vorgaenge.
*/
readonly wert?: Prisma.InputJsonValue | null;
/** Fehlt das Feld, bleibt die vorhandene Notiz stehen; `null` loescht sie. */
readonly notiz?: string | null;
};
function text(wert: string | null | undefined): string | null {
return wert?.trim() || null;
}
/**
* Die Notizpflicht, an einer Stelle.
*
* Geprueft wird der **Zustand danach**: wer einen Schritt mit vorhandener Notiz
* auf `NICHT_ZUTREFFEND` setzt, muss sie nicht noch einmal eintippen; wer die
* Notiz im selben Zug leert, faellt hierauf.
*/
function notizPflichtPruefen(zustand: SchrittZustand, notiz: string | null): void {
if (notiz !== null) return;
if (zustand === SchrittZustand.NICHT_ZUTREFFEND) {
throw new LaufFehler(
"Ein Schritt, der nicht zutrifft, braucht eine Begründung — sonst steht im Archiv ein " +
"übersprungener Schritt, und niemand weiß mehr, warum.",
);
}
if (zustand === SchrittZustand.FEHLGESCHLAGEN) {
throw new LaufFehler(
"Ein fehlgeschlagener Schritt braucht eine Notiz: was ist passiert und was ist zu tun?",
);
}
}
/**
* Prueft, dass dieser Schritt zu diesem Lauf gehoert.
*
* Die Schrittkennung kommt von aussen. Ohne diese Pruefung liesse sich ein
* Schritt einer fremden Version — und damit eines fremden Kunden — an diesen Lauf
* haengen; in der Abarbeitungsansicht tauchte er nie auf, im Archiv aber schon.
*/
async function schrittDesLaufsHolen(
tx: Prisma.TransactionClient,
vorlagenSchrittId: string,
vorlagenVersionId: string,
): Promise<{ readonly id: string; readonly titel: string; readonly pflicht: boolean }> {
const schritt = await tx.vorlagenSchritt.findUnique({
where: { id: vorlagenSchrittId },
select: { id: true, titel: true, pflicht: true, abschnitt: { select: { versionId: true } } },
});
if (!schritt || schritt.abschnitt.versionId !== vorlagenVersionId) {
throw new LaufFehler("Dieser Schritt gehört nicht zu diesem Lauf.");
}
return schritt;
}
/**
* Setzt ein Schrittergebnis.
*
* Nebenwirkung mit Absicht: ein `OFFEN`er Lauf wechselt dabei auf `IN_ARBEIT`.
* Wer einen Schritt eintraegt, arbeitet den Lauf ab — ein Lauf mit
* Schrittergebnissen, aber ohne `gestartetAm`, waere eine Zeitleiste, die luegt.
*
* Aendert die Eingabe nichts, wird auch nichts geschrieben: ein Doppelklick soll
* keine zwei Protokolleintraege ergeben.
*/
export async function schrittergebnisSetzen(
eingabe: SchrittergebnisEingabe,
benutzer: HandelnderBenutzer,
): Promise<Schrittergebnis> {
return prisma.$transaction(async (tx) => {
const lauf = await laufZumSchreibenHolen(tx, eingabe.laufId, benutzer);
const schritt = await schrittDesLaufsHolen(tx, eingabe.vorlagenSchrittId, lauf.vorlagenVersionId);
const vorher = await tx.schrittergebnis.findUnique({
where: { laufId_vorlagenSchrittId: { laufId: lauf.id, vorlagenSchrittId: schritt.id } },
});
const notiz = eingabe.notiz === undefined ? (vorher?.notiz ?? null) : text(eingabe.notiz);
notizPflichtPruefen(eingabe.zustand, notiz);
const wertGeaendert =
eingabe.wert !== undefined &&
JSON.stringify(eingabe.wert ?? null) !== JSON.stringify(vorher?.wert ?? null);
if (vorher && vorher.zustand === eingabe.zustand && vorher.notiz === notiz && !wertGeaendert) {
return vorher;
}
const wert: Prisma.InputJsonValue | typeof Prisma.DbNull | undefined =
eingabe.wert === undefined ? undefined : (eingabe.wert ?? Prisma.DbNull);
const nachher = await tx.schrittergebnis.upsert({
where: { laufId_vorlagenSchrittId: { laufId: lauf.id, vorlagenSchrittId: schritt.id } },
create: {
laufId: lauf.id,
vorlagenSchrittId: schritt.id,
zustand: eingabe.zustand,
notiz,
...(wert === undefined ? {} : { wert }),
aktualisiertVonId: benutzer.id,
},
update: {
zustand: eingabe.zustand,
notiz,
...(wert === undefined ? {} : { wert }),
aktualisiertVonId: benutzer.id,
},
});
await tx.protokoll.create({
data: {
laufId: lauf.id,
entitaet: "Schrittergebnis",
entitaetId: nachher.id,
aktion: ZUSTAND_AKTION[eingabe.zustand],
benutzerId: benutzer.id,
vorher: vorher
? { zustand: vorher.zustand, wert: vorher.wert ?? null, notiz: vorher.notiz }
: Prisma.DbNull,
nachher: {
schritt: schritt.titel,
pflicht: schritt.pflicht,
zustand: nachher.zustand,
wert: nachher.wert ?? null,
notiz: nachher.notiz,
},
},
});
const status = await laufStartenFallsOffen(tx, lauf, benutzer.id);
await abschlussPruefenNachAenderung(tx, { id: lauf.id, status }, benutzer.id);
return nachher;
});
}
export type AbschnittEingabe = {
readonly laufId: string;
readonly abschnittId: string;
/** Gilt fuer jeden Schritt des Abschnitts – einmal eingetippt, einmal gespeichert. */
readonly notiz: string;
};
export type AbschnittErgebnis = {
readonly gesetzt: number;
/** Schritte, die schon `ERLEDIGT` waren und deshalb geblieben sind. */
readonly uebersprungen: readonly string[];
};
/**
* Setzt einen ganzen Abschnitt auf `NICHT_ZUTREFFEND`.
*
* Der Fall aus dem Auftrag: im Onboarding gibt es zwei Konto-Abschnitte, und je
* nach Kunde trifft einer davon gar nicht zu. Achtzehn Haken einzeln mit
* derselben Begruendung zu versehen macht niemand — und wer es nicht macht, laesst
* sie offen.
*
* **Schon `ERLEDIGT`e Schritte bleiben `ERLEDIGT`.** Das ist die interessante
* Entscheidung, deshalb der Grund: „erledigt“ ist die Aussage, dass jemand etwas
* wirklich getan hat, mit Namen und Zeitstempel. „Nicht zutreffend“ ist die
* Aussage, dass es nichts zu tun gab. Die zweite ueber die erste zu schreiben
* waere eine Tatsache durch eine Behauptung zu ersetzen und wuerde das „wer wann“
* dieser Arbeit loeschen. Wer wirklich einen erledigten Schritt zurueckdrehen
* will, tut das einzeln und begruendet es dort. Die Zahl der uebersprungenen
* Schritte kommt zurueck, damit die Oberflaeche es sagen kann.
*
* `FEHLGESCHLAGEN` wird dagegen ueberschrieben: ein Schritt, der nicht zutrifft,
* kann nicht fehlgeschlagen sein — er haette gar nicht versucht werden muessen.
*/
export async function abschnittNichtZutreffend(
eingabe: AbschnittEingabe,
benutzer: HandelnderBenutzer,
): Promise<AbschnittErgebnis> {
const notiz = text(eingabe.notiz);
if (!notiz) {
throw new LaufFehler(
"Ein ganzer Abschnitt, der nicht zutrifft, braucht eine Begründung — sie steht danach an " +
"jedem Schritt des Abschnitts.",
);
}
return prisma.$transaction(async (tx) => {
const lauf = await laufZumSchreibenHolen(tx, eingabe.laufId, benutzer);
const abschnitt = await tx.vorlagenAbschnitt.findUnique({
where: { id: eingabe.abschnittId },
select: {
titel: true,
versionId: true,
schritte: { orderBy: { position: "asc" }, select: { id: true, titel: true, pflicht: true } },
},
});
if (!abschnitt || abschnitt.versionId !== lauf.vorlagenVersionId) {
throw new LaufFehler("Dieser Abschnitt gehört nicht zu diesem Lauf.");
}
const vorhandene = new Map(
(
await tx.schrittergebnis.findMany({
where: { laufId: lauf.id, vorlagenSchrittId: { in: abschnitt.schritte.map((s) => s.id) } },
})
).map((ergebnis) => [ergebnis.vorlagenSchrittId, ergebnis]),
);
const uebersprungen: string[] = [];
let gesetzt = 0;
for (const schritt of abschnitt.schritte) {
const vorher = vorhandene.get(schritt.id);
if (vorher?.zustand === SchrittZustand.ERLEDIGT) {
uebersprungen.push(schritt.titel);
continue;
}
if (vorher?.zustand === SchrittZustand.NICHT_ZUTREFFEND && vorher.notiz === notiz) {
// Schon so eingetragen, mit derselben Begruendung: nichts zu tun und kein
// zweiter Protokolleintrag.
continue;
}
const nachher = await tx.schrittergebnis.upsert({
where: { laufId_vorlagenSchrittId: { laufId: lauf.id, vorlagenSchrittId: schritt.id } },
create: {
laufId: lauf.id,
vorlagenSchrittId: schritt.id,
zustand: SchrittZustand.NICHT_ZUTREFFEND,
notiz,
aktualisiertVonId: benutzer.id,
},
update: {
zustand: SchrittZustand.NICHT_ZUTREFFEND,
notiz,
aktualisiertVonId: benutzer.id,
},
});
gesetzt += 1;
// Je Schritt ein Eintrag: „jede Aenderung an einem Schrittergebnis steht im
// Protokoll“ gilt auch, wenn achtzehn davon aus einem Klick entstehen. Der
// Abschnitt steht dabei, damit die Herkunft erkennbar bleibt.
await tx.protokoll.create({
data: {
laufId: lauf.id,
entitaet: "Schrittergebnis",
entitaetId: nachher.id,
aktion: "Abschnitt als nicht zutreffend eingetragen",
benutzerId: benutzer.id,
vorher: vorher
? { zustand: vorher.zustand, notiz: vorher.notiz }
: { zustand: SchrittZustand.OFFEN, notiz: null },
nachher: {
abschnitt: abschnitt.titel,
schritt: schritt.titel,
pflicht: schritt.pflicht,
zustand: SchrittZustand.NICHT_ZUTREFFEND,
notiz,
},
},
});
}
if (gesetzt > 0) {
const status = await laufStartenFallsOffen(tx, lauf, benutzer.id);
await abschlussPruefenNachAenderung(tx, { id: lauf.id, status }, benutzer.id);
}
return { gesetzt, uebersprungen };
});
}
+367
View File
@@ -0,0 +1,367 @@
import { expect, test, type Page } from "@playwright/test";
import "dotenv/config";
import { prisma } from "@/lib/db";
/**
* Ein Lauf von vorn bis hinten durch die Oberflaeche (#013).
*
* Das ist der Hauptablauf des ganzen Auftrags: Lauf starten, abarbeiten,
* abschliessen, archivieren — und danach ist er unveraenderlich. Geprueft wird,
* was nur mit angehaengtem React gelingt, und danach, ob es wirklich in der
* Datenbank steht: durch Neuladen.
*
* Voraussetzung: ein **gebauter** Stand laeuft (`pnpm build && pnpm start`) und
* `pnpm beispiel:vorlage` hat die Beispielvorlage angelegt.
*/
const STEMPEL = Date.now();
const TITEL = `ZZ E2E Lauf ${STEMPEL}`;
async function anmelden(seite: Page) {
const email = process.env["SEED_ADMIN_EMAIL"];
const passwort = process.env["SEED_ADMIN_PASSWORD"];
if (!email || !passwort) throw new Error("SEED_ADMIN_EMAIL und SEED_ADMIN_PASSWORD fehlen.");
await seite.goto("/anmelden");
await seite.fill('input[name="email"]', email);
await seite.fill('input[name="passwort"]', passwort);
await Promise.all([seite.waitForURL("**/"), seite.click('button[type="submit"]')]);
}
/** Legt einen Lauf an und gibt seine Adresse zurueck. */
async function laufStarten(seite: Page, titel: string): Promise<string> {
await seite.goto("/laeufe/starten");
await seite.selectOption('select[name="vorlagenVersionId"]', { index: 1 });
await seite.selectOption('select[name="geraetId"]', { index: 1 });
await seite.locator("details summary", { hasText: "Titel, Ticket" }).click();
await seite.getByLabel("Titel", { exact: true }).fill(titel);
// Pflichtvariablen der Vorlage fuellen – welche es sind, weiss nur die Vorlage.
for (const feld of await seite.locator("fieldset select").all()) {
await feld.selectOption({ index: 1 });
}
for (const feld of await seite.locator('fieldset input[type="number"]').all()) {
await feld.fill("16");
}
await seite.getByRole("button", { name: "Lauf anlegen" }).click();
await seite.waitForURL(/\/laeufe\/(?!starten)[^/]+$/, { timeout: 30_000 });
return new URL(seite.url()).pathname;
}
test.describe("Ein Lauf von vorn bis hinten", () => {
test("starten, abarbeiten, abschließen, archivieren", async ({ page }) => {
test.slow();
await anmelden(page);
const pfad = await laufStarten(page, TITEL);
// Ein neuer Lauf ist **offen** – das ist die Zuweisung, nicht der Start.
await expect(page.getByRole("button", { name: "Lauf beginnen" })).toBeVisible();
await expect(page.getByRole("button", { name: "Lauf abschließen" })).toHaveCount(0);
await page.getByRole("button", { name: "Lauf beginnen" }).click();
await expect(page.getByRole("button", { name: "Lauf abschließen" })).toBeVisible();
// Solange Pflichtschritte offen sind, ist der Abschluss gesperrt.
await expect(page.getByRole("button", { name: "Lauf abschließen" })).toBeDisabled();
/*
* Alle Schritte erledigen – einzeln und abschnittsweise, so wie ein
* Techniker es täte. Nach jedem Haken wird auf den **Zähler im Kopf des
* Abschnitts** gewartet: der steht erst, wenn der Server gespeichert hat.
* Ohne dieses Warten trifft der nächste Klick einen Knopf aus dem alten
* Baum — und weil ein Klick auf einen schon gehakten Schritt ihn
* zurücknimmt, zählte der Test rückwärts.
*/
const abschnitte = page.locator("section").filter({ has: page.locator("button[aria-expanded]") });
const anzahlAbschnitte = await abschnitte.count();
expect(anzahlAbschnitte).toBeGreaterThan(0);
for (let i = 0; i < anzahlAbschnitte; i += 1) {
const abschnitt = abschnitte.nth(i);
const kopf = abschnitt.locator("button[aria-expanded]").first();
if ((await kopf.getAttribute("aria-expanded")) === "false") await kopf.click();
// Über die Haken zählen, nicht über `listitem`: die Kommentare eines
// Schritts stehen ebenfalls in einer Liste und zählten sonst mit.
const schritte = await abschnitt.getByRole("button", { name: "erledigt", exact: true }).count();
for (let j = 0; j < schritte; j += 1) {
await abschnitt.getByRole("button", { name: "erledigt", exact: true }).nth(j).click();
await expect(kopf).toContainText(`${j + 1} von ${schritte}`);
}
}
/*
* Der Beweis, dass es nicht nur im Browser steht: neu laden. Alles ist vom
* Tisch, also sind alle Abschnitte zugeklappt — erst wieder aufklappen und
* dann nachsehen.
*/
await page.reload();
const ersterAbschnitt = abschnitte.first();
await ersterAbschnitt.locator("button[aria-expanded]").first().click();
await expect(
ersterAbschnitt.getByRole("button", { name: "erledigt", exact: true }).first(),
).toHaveAttribute("aria-pressed", "true");
await expect(page.getByRole("button", { name: "Lauf abschließen" })).toBeEnabled();
await page.getByRole("button", { name: "Lauf abschließen" }).click();
await expect(page.getByRole("button", { name: "archivieren" })).toBeVisible();
// Archivieren ist die zweite, ausdrückliche Handlung – mit Rückfrage.
await page.getByRole("button", { name: "archivieren" }).click();
await page.getByRole("button", { name: "Ja, archivieren" }).click();
await expect(page.getByText(/Dieser Lauf ist archiviert/)).toBeVisible();
// Und danach ändert sich nichts mehr: keine Knöpfe, kein Kommentarfeld.
await page.goto(pfad);
await expect(page.getByRole("button", { name: "erledigt", exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Kommentar hinzufügen" })).toHaveCount(0);
});
test("verlangt für „nicht zutreffend“ eine Begründung", async ({ page }) => {
test.slow();
await anmelden(page);
await laufStarten(page, `${TITEL} B`);
await page.getByRole("button", { name: "Lauf beginnen" }).click();
// Der Klick allein speichert nicht – erst erscheint das Begründungsfeld.
await page.getByRole("button", { name: "nicht zutreffend" }).first().click();
const speichern = page.getByRole("button", { name: "Mit Begründung speichern" });
await expect(speichern).toBeVisible();
await expect(speichern).toBeDisabled();
await page.getByLabel("Begründung").fill("Dieser Kunde hat kein Active Directory.");
await expect(speichern).toBeEnabled();
await speichern.click();
/*
* Erst auf den gespeicherten Stand warten, dann neu laden: die Aktion
* frischt die Seite selbst auf, und ein `reload` mitten hinein bricht sie
* ab. Am Handy war dieses Fenster groß genug, dass der Test daran
* gescheitert ist.
*/
const schritt = page.getByRole("listitem").filter({ hasText: "Ticket und Endbenutzer" });
await expect(schritt.getByText("nicht zutreffend", { exact: true }).last()).toBeVisible();
await page.reload();
// Die Begründung steht im Feld, wo sie sich auch ändern lässt – und nur dort.
await expect(schritt.getByLabel("Begründung")).toHaveValue("Dieser Kunde hat kein Active Directory.");
});
test("nimmt „nicht zutreffend“ auf Klick wieder zurück", async ({ page }) => {
/*
* Der Blocker aus dem Review zu #013: ein Klick auf die schon gewählte Pille
* setzte den Zustand nur im Browser zurück. Auf dem Bildschirm war danach
* nichts mehr gewählt, in der Datenbank stand weiter „nicht zutreffend“ —
* und es gab keinen Weg mehr, das zu ändern.
*/
test.slow();
await anmelden(page);
await laufStarten(page, `${TITEL} F`);
await page.getByRole("button", { name: "Lauf beginnen" }).click();
const schritt = page.getByRole("listitem").filter({ hasText: "Ticket und Endbenutzer" });
const pille = schritt.getByRole("button", { name: "nicht zutreffend" });
await pille.click();
await schritt.getByLabel("Begründung").fill("Gerät ist neu ausgepackt.");
await schritt.getByRole("button", { name: "Mit Begründung speichern" }).click();
await expect(pille).toHaveAttribute("aria-pressed", "true");
// Zurücknehmen braucht keine Begründung – es nimmt ja gerade eine zurück.
await pille.click();
// Erst das Speichern abwarten: ein `reload` mitten in der Aktion bricht sie ab.
await page.waitForLoadState("networkidle");
await page.reload();
await expect(pille).toHaveAttribute("aria-pressed", "false");
await expect(schritt.getByLabel("Begründung")).toHaveCount(0);
});
test("zeigt einen Befehl mit eingesetzten Werten und einem Kopier-Knopf", async ({ page }) => {
test.slow();
await anmelden(page);
await laufStarten(page, `${TITEL} C`);
/*
* Der Befehl **am Schritt**, nicht die Skript-Kopie: die steckt in einem
* zugeklappten `details` und ist deshalb unsichtbar, bis jemand sie
* aufklappt.
*/
const befehl = page.locator("li pre.mono").first();
await expect(befehl).toBeVisible();
await expect(befehl).not.toContainText("{{hostname}}");
await expect(page.getByRole("button", { name: "Befehl kopieren" }).first()).toBeVisible();
await expect(page.getByText("Alle Befehle als Skript kopieren")).toBeVisible();
});
test("trägt am Handy ohne horizontales Scrollen", async ({ page, isMobile }) => {
test.skip(!isMobile, "Nur im Projekt „handy“.");
test.slow();
await anmelden(page);
await laufStarten(page, `${TITEL} D`);
const masse = await page.evaluate(() => ({
scroll: document.documentElement.scrollWidth,
sichtbar: document.documentElement.clientWidth,
}));
expect(masse.scroll).toBeLessThanOrEqual(masse.sichtbar);
});
});
test.describe("Der Zustands-Endpunkt", () => {
/*
* Geprueft wird hier der **Route Handler**, nicht die Abfrage darunter: dass
* er sein Recht selbst prueft, einen fremden oder unbekannten Lauf als „nicht
* gefunden“ beantwortet und nichts zwischenspeichern laesst. Die Huelle unter
* `src/app/(app)/layout.tsx` deckt `src/app/api/` nicht ab — das ist genau der
* Grund, warum es diesen Test gibt (Reviewbefund zu #013).
*/
test("antwortet ohne Anmeldung mit 401", async ({ request }) => {
const antwort = await request.get("/api/laeufe/egal/zustand");
expect(antwort.status()).toBe(401);
});
test("liefert angemeldet den Zustand ohne Befehle und ohne Zwischenspeicher", async ({ page }) => {
test.slow();
await anmelden(page);
const pfad = await laufStarten(page, `${TITEL} E`);
const laufId = pfad.split("/").pop();
const antwort = await page.request.get(`/api/laeufe/${laufId}/zustand`);
expect(antwort.status()).toBe(200);
expect(antwort.headers()["cache-control"]).toBe("no-store");
const zustand: unknown = await antwort.json();
expect(Object.keys(zustand as object).sort()).toEqual([
"anzahlKommentare",
"anzahlPflichtOffen",
"geaendertAm",
"schritte",
"status",
]);
expect(JSON.stringify(zustand)).not.toContain("Rename-Computer");
});
test("gibt einen Lauf, den es nicht gibt, als 404 zurück", async ({ page }) => {
await anmelden(page);
const antwort = await page.request.get("/api/laeufe/gibtsnicht/zustand");
expect(antwort.status()).toBe(404);
});
});
test.describe("Namensvorschläge im Start-Dialog", () => {
/*
* Der Onboarding-Fall: die Person steht noch nicht im Verzeichnis, ihre
* Anmeldenamen entstehen erst hier. Die Regeln liegen im Dienst (#012) — dass
* der Dialog sie auch wirklich erreicht, sieht man nur so.
*/
const VORLAGE = `ZZ E2E Onboarding ${STEMPEL}`;
test.beforeAll(async () => {
await prisma.einstellung.update({
where: { id: "global" },
data: {
namensregeln: {
upn: "{vorname}.{nachname}@firma.de",
sam: "{v}{nachname}",
mail: "{vorname}.{nachname}@firma.de",
},
},
});
const vorlage = await prisma.vorlage.create({ data: { name: VORLAGE, kategorie: "ZZ Test" } });
const version = await prisma.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 1, status: "ENTWURF" },
});
for (const [position, schluessel] of ["upn", "sam", "mail"].entries()) {
await prisma.vorlagenVariable.create({
data: { versionId: version.id, schluessel, beschriftung: schluessel, position },
});
}
const abschnitt = await prisma.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: "Konto" },
});
await prisma.vorlagenSchritt.create({
data: { abschnittId: abschnitt.id, titel: "Konto angelegt" },
});
await prisma.vorlagenVersion.update({
where: { id: version.id },
data: { status: "VEROEFFENTLICHT", veroeffentlichtAm: new Date() },
});
});
/** Waehlt die Testvorlage – ueber den Wert, nicht ueber die Beschriftung. */
async function vorlageWaehlen(seite: Page) {
const wert = await seite
.locator('select[name="vorlagenVersionId"] option', { hasText: VORLAGE })
.getAttribute("value");
if (!wert) throw new Error(`Die Vorlage „${VORLAGE}“ steht nicht zur Auswahl.`);
await seite.selectOption('select[name="vorlagenVersionId"]', wert);
}
test("füllt upn, sam und mail nach den Regeln", async ({ page }) => {
test.slow();
await anmelden(page);
await page.goto("/laeufe/starten");
await vorlageWaehlen(page);
await page.getByLabel("… oder Name als Freitext").fill("Jörg Müller-Lüdenscheid");
await page.getByRole("button", { name: "Namen aus den Regeln vorschlagen" }).click();
/*
* Genau der Fall aus dem Auftrag: Umlaute gefaltet, `sam` auf 20 Zeichen
* gekuerzt. Die Zahl am Ende ist kein Zufall — traegt die Datenbank den
* Namen schon (die Beispieldaten tun es), haengt die Regel einen Zaehler an.
* Ob mit oder ohne, entscheidet der Bestand, deshalb steht hier ein Muster.
*/
await expect(page.getByLabel("upn")).toHaveValue(/^joerg\.mueller-luedenscheid\d*@firma\.de$/u);
await expect(page.getByLabel("sam")).toHaveValue(/^jmuellerluedensch\d*$/u);
await expect(page.getByLabel("mail")).toHaveValue(/^joerg\.mueller-luedenscheid\d*@firma\.de$/u);
});
test("überschreibt nichts, was schon dasteht", async ({ page }) => {
test.slow();
await anmelden(page);
await page.goto("/laeufe/starten");
await vorlageWaehlen(page);
await page.getByLabel("… oder Name als Freitext").fill("Maria Huber");
await page.getByLabel("upn").fill("chefin@firma.de");
await page.getByRole("button", { name: "Namen aus den Regeln vorschlagen" }).click();
await expect(page.getByLabel("sam")).toHaveValue(/^mhuber\d*$/u);
await expect(page.getByLabel("upn")).toHaveValue("chefin@firma.de");
});
test.afterAll(async () => {
const vorlage = await prisma.vorlage.findFirst({
where: { name: VORLAGE },
select: { id: true },
});
if (vorlage) await prisma.vorlage.delete({ where: { id: vorlage.id } });
await prisma.einstellung.update({ where: { id: "global" }, data: { namensregeln: {} } });
});
});
test.afterAll(async () => {
/*
* Aufgeraeumt wird ueber die Datenbank: die Anwendung kennt fuer einen Lauf
* keinen Loeschweg, und das ist richtig so. Ohne diesen Nachlauf saehe jede
* Datenbank, gegen die diese Tests laufen, nach ein paar Durchlaeufen aus wie
* ein Messie-Keller.
*/
const laeufe = await prisma.lauf.findMany({
where: { titel: { startsWith: "ZZ E2E Lauf" } },
select: { id: true },
});
const kennungen = laeufe.map((lauf) => lauf.id);
if (kennungen.length > 0) {
await prisma.kommentar.deleteMany({ where: { laufId: { in: kennungen } } });
await prisma.schrittergebnis.deleteMany({ where: { laufId: { in: kennungen } } });
// `Protokoll.laufId` steht auf `Restrict` – ohne diese Zeile bleibt der Lauf
// stehen, und der Fremdschluessel meldet sich.
await prisma.protokoll.deleteMany({
where: { OR: [{ laufId: { in: kennungen } }, { entitaetId: { in: kennungen } }] },
});
await prisma.lauf.deleteMany({ where: { id: { in: kennungen } } });
}
await prisma.$disconnect();
});
+1 -1
View File
@@ -13,7 +13,7 @@ import "dotenv/config";
* Voraussetzung: ein **gebauter** Stand laeuft (`pnpm build && pnpm start`) und
* `SEED_ADMIN_EMAIL`/`SEED_ADMIN_PASSWORD` sind gesetzt. Gegen `pnpm dev`
* schlaegt das hier fehl, und das ist richtig so — siehe CLAUDE.md,
* „Oberflaeche pruefen".
* „Oberflaeche pruefen“.
*
* Der Ablauf legt seine eigene Vorlage an und **archiviert** sie am Ende. Nicht
* loescht: eine veroeffentlichte Version laesst sich nicht mehr entfernen, auch
+194
View File
@@ -0,0 +1,194 @@
import { beforeEach, describe, expect, it } from "vitest";
import { Betriebsmodus, LaufStatus, Rolle, SchrittZustand, VersionStatus } from "@/generated/prisma/enums";
import { LaufFehler, laufAbschliessen, laufAnlegen } from "@/server/services/laeufe";
import { schrittergebnisSetzen } from "@/server/services/schrittergebnisse";
import { KOMMENTAR_HOECHSTLAENGE, kommentarHinzufuegen } from "@/server/services/kommentare";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Kommentare an Läufen und Schrittergebnissen (#013).
*
* Ein Kommentar ist der Ort für das, was in kein Feld passt. Er trägt Urheber und
* Zeitpunkt selbst, wird nie geändert — und kommt an einem archivierten Lauf
* nicht mehr dazu.
*/
async function grundbestand() {
const eigene = await testPrisma.kunde.create({
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({ data: { id: "global", modus: Betriebsmodus.INTERN } });
const adminZeile = await testPrisma.benutzer.create({
data: { email: "admin@firma.example", name: "Alex Admin", rolle: Rolle.ADMIN },
});
const technikerZeile = await testPrisma.benutzer.create({
data: { email: "technik@firma.example", name: "Tina Technik", rolle: Rolle.TECHNIKER },
});
return {
eigene,
admin: { id: adminZeile.id, rolle: Rolle.ADMIN },
techniker: { id: technikerZeile.id, rolle: Rolle.TECHNIKER },
};
}
async function vorlageMitVersion() {
const vorlage = await testPrisma.vorlage.create({ data: { name: "Offboarding" } });
const version = await testPrisma.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF },
});
const abschnitt = await testPrisma.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: "Rückgabe", position: 0 },
});
const geraet = await testPrisma.vorlagenSchritt.create({
data: { abschnittId: abschnitt.id, titel: "Notebook zurück", pflicht: true, position: 0 },
});
await testPrisma.vorlagenVersion.update({
where: { id: version.id },
data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() },
});
return { version, schritte: { geraet } };
}
describe.runIf(datenbankVerfuegbar)("Kommentare", () => {
beforeEach(async () => {
await datenbankLeeren();
delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
});
it("schreibt einen Kommentar an den Lauf", async () => {
const { admin, techniker } = await grundbestand();
const { version } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
const kommentar = await kommentarHinzufuegen(
{ laufId: lauf.id, rumpf: " Kunde war nicht erreichbar, neuer Termin am Montag. " },
techniker,
);
expect(kommentar.laufId).toBe(lauf.id);
expect(kommentar.schrittergebnisId).toBeNull();
expect(kommentar.benutzerId).toBe(techniker.id);
expect(kommentar.rumpf).toBe("Kunde war nicht erreichbar, neuer Termin am Montag.");
});
it("schreibt einen Kommentar an ein Schrittergebnis", async () => {
const { admin } = await grundbestand();
const { version, schritte } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
const ergebnis = await schrittergebnisSetzen(
{
laufId: lauf.id,
vorlagenSchrittId: schritte.geraet.id,
zustand: SchrittZustand.FEHLGESCHLAGEN,
notiz: "Netzteil fehlt.",
},
admin,
);
const kommentar = await kommentarHinzufuegen(
{ laufId: lauf.id, schrittergebnisId: ergebnis.id, rumpf: "Ersatz ist bestellt." },
admin,
);
expect(kommentar.schrittergebnisId).toBe(ergebnis.id);
});
it("startet den Lauf nicht", async () => {
const { admin } = await grundbestand();
const { version } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
await kommentarHinzufuegen({ laufId: lauf.id, rumpf: "Rückfrage an den Kunden gestellt." }, admin);
// Eine Rückfrage zu notieren ist keine Abarbeitung.
const nachher = await testPrisma.lauf.findUniqueOrThrow({ where: { id: lauf.id } });
expect(nachher.status).toBe(LaufStatus.OFFEN);
expect(nachher.gestartetAm).toBeNull();
});
it("nimmt einen Kommentar an einem abgeschlossenen Lauf noch an", async () => {
const { admin } = await grundbestand();
const { version, schritte } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
await schrittergebnisSetzen(
{ laufId: lauf.id, vorlagenSchrittId: schritte.geraet.id, zustand: SchrittZustand.ERLEDIGT },
admin,
);
await laufAbschliessen(lauf.id, admin);
// Das Nachfassen kommt nach dem Abschluss — gesperrt wird erst durch das
// Archivieren (siehe laufMussVeraenderbarSein).
const kommentar = await kommentarHinzufuegen(
{ laufId: lauf.id, rumpf: "Nachtrag: Netzteil ist eine Woche später eingetroffen." },
admin,
);
expect(kommentar.id).toBeTruthy();
});
it("lehnt einen leeren und einen zu langen Kommentar ab", async () => {
const { admin } = await grundbestand();
const { version } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
await expect(kommentarHinzufuegen({ laufId: lauf.id, rumpf: " " }, admin)).rejects.toThrow(LaufFehler);
await expect(
kommentarHinzufuegen({ laufId: lauf.id, rumpf: "x".repeat(KOMMENTAR_HOECHSTLAENGE + 1) }, admin),
).rejects.toThrow(new RegExp(String(KOMMENTAR_HOECHSTLAENGE)));
expect(await testPrisma.kommentar.count()).toBe(0);
});
it("lehnt ein Schrittergebnis eines anderen Laufs ab", async () => {
const { admin } = await grundbestand();
const { version, schritte } = await vorlageMitVersion();
const einer = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
const anderer = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
const fremdesErgebnis = await testPrisma.schrittergebnis.findFirstOrThrow({
where: { laufId: anderer.id, vorlagenSchrittId: schritte.geraet.id },
});
// Die Kennung kommt von außen: ohne diese Prüfung hinge der Kommentar an
// einem Schritt eines fremden Laufs.
await expect(
kommentarHinzufuegen(
{ laufId: einer.id, schrittergebnisId: fremdesErgebnis.id, rumpf: "Gehört hier nicht hin." },
admin,
),
).rejects.toThrow(/gehört nicht zu diesem Lauf/);
expect(await testPrisma.kommentar.count()).toBe(0);
});
it("lässt einen Techniker nicht an den Lauf eines fremden Kunden", async () => {
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "laufzettel-";
const eigene = await testPrisma.kunde.create({
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({
data: { id: "global", modus: Betriebsmodus.DIENSTLEISTUNG },
});
const adminZeile = await testPrisma.benutzer.create({
data: { email: "admin@firma.example", name: "Alex Admin", rolle: Rolle.ADMIN },
});
const technikerZeile = await testPrisma.benutzer.create({
data: { email: "technik@firma.example", name: "Tina Technik", rolle: Rolle.TECHNIKER },
});
const kundeA = await testPrisma.kunde.create({ data: { name: "Kunde A", kuerzel: "KA" } });
await testPrisma.benutzerKunde.create({
data: { benutzerId: technikerZeile.id, kundeId: eigene.id },
});
const { version } = await vorlageMitVersion();
const lauf = await laufAnlegen(
{ vorlagenVersionId: version.id, kundeId: kundeA.id },
{ id: adminZeile.id, rolle: Rolle.ADMIN },
);
await expect(
kommentarHinzufuegen(
{ laufId: lauf.id, rumpf: "Darf ich das sehen?" },
{
id: technikerZeile.id,
rolle: Rolle.TECHNIKER,
},
),
).rejects.toThrow(/Berechtigung/);
expect(await testPrisma.kommentar.count()).toBe(0);
});
});
+888
View File
@@ -0,0 +1,888 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
Betriebsmodus,
LaufStatus,
Rolle,
SchrittTyp,
SchrittZustand,
VariablenTyp,
VersionStatus,
} from "@/generated/prisma/enums";
import {
LaufFehler,
laufAbschliessen,
laufAnlegen,
laufArchivieren,
laufKopfdatenAendern,
laufMitAdKontoVerknuepfen,
laufMussVeraenderbarSein,
laufZurBearbeitungOeffnen,
laufZurueckholen,
titelVorschlag,
titelVorschlagErmitteln,
} from "@/server/services/laeufe";
import { schrittergebnisSetzen } from "@/server/services/schrittergebnisse";
import { kommentarHinzufuegen } from "@/server/services/kommentare";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Läufe: anlegen, starten, abschließen, archivieren (#013).
*
* Die Zusage, an der der Wert des ganzen Archivs hängt: ein archivierter Lauf
* ändert sich nicht mehr, und nur ein ADMIN hebt das auf. Dazu die Regeln, die
* einen Lauf überhaupt brauchbar machen — er hängt für immer an seiner Version,
* `OFFEN` ist der Zuweisungszustand, und die Mandantengrenze gilt an jeder
* Kennung, die von außen kommt.
*/
async function grundbestand(modus: Betriebsmodus = Betriebsmodus.INTERN) {
const eigene = await testPrisma.kunde.create({
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({ data: { id: "global", modus } });
const adminZeile = await testPrisma.benutzer.create({
data: { email: "admin@firma.example", name: "Alex Admin", rolle: Rolle.ADMIN },
});
const technikerZeile = await testPrisma.benutzer.create({
data: { email: "technik@firma.example", name: "Tina Technik", rolle: Rolle.TECHNIKER },
});
const leserZeile = await testPrisma.benutzer.create({
data: { email: "leser@firma.example", name: "Lars Leser", rolle: Rolle.LESER },
});
return {
eigene,
admin: { id: adminZeile.id, rolle: Rolle.ADMIN },
techniker: { id: technikerZeile.id, rolle: Rolle.TECHNIKER },
leser: { id: leserZeile.id, rolle: Rolle.LESER },
};
}
type VariablenVorgabe = {
readonly schluessel: string;
readonly beschriftung: string;
readonly typ?: VariablenTyp;
readonly optionen?: readonly string[];
readonly pflicht?: boolean;
};
/**
* Eine Vorlage mit einer veröffentlichten Version 1: vier Schritte, drei davon
* Pflicht.
*
* Der Inhalt entsteht **vor** dem Veröffentlichen — die Trigger aus #009 lassen
* an einer veröffentlichten Version nichts mehr einfügen, und genau das ist der
* Sinn dieser Tests von der anderen Seite.
*/
async function vorlageMitVersion(vorgabe?: {
readonly kundeId?: string | null;
readonly variablen?: readonly VariablenVorgabe[];
readonly name?: string;
}) {
const vorlage = await testPrisma.vorlage.create({
data: { name: vorgabe?.name ?? "PC-Neuinstallation", kundeId: vorgabe?.kundeId ?? null },
});
const version = await testPrisma.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF },
});
for (const [index, variable] of (vorgabe?.variablen ?? []).entries()) {
await testPrisma.vorlagenVariable.create({
data: {
versionId: version.id,
schluessel: variable.schluessel,
beschriftung: variable.beschriftung,
typ: variable.typ ?? VariablenTyp.TEXT,
optionen: [...(variable.optionen ?? [])],
pflicht: variable.pflicht ?? false,
position: index,
},
});
}
const vorbereitung = await testPrisma.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: "Vorbereitung", position: 0 },
});
const sicherung = await testPrisma.vorlagenSchritt.create({
data: { abschnittId: vorbereitung.id, titel: "Datensicherung geprüft", pflicht: true, position: 0 },
});
const notizSchritt = await testPrisma.vorlagenSchritt.create({
data: {
abschnittId: vorbereitung.id,
titel: "Auffälligkeiten notiert",
typ: SchrittTyp.TEXT,
pflicht: false,
position: 1,
},
});
const konto = await testPrisma.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: "Konto im AD", position: 1 },
});
const kontoAngelegt = await testPrisma.vorlagenSchritt.create({
data: { abschnittId: konto.id, titel: "Konto angelegt", pflicht: true, position: 0 },
});
const gruppen = await testPrisma.vorlagenSchritt.create({
data: { abschnittId: konto.id, titel: "Gruppen zugewiesen", pflicht: true, position: 1 },
});
await testPrisma.vorlagenVersion.update({
where: { id: version.id },
data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() },
});
return {
vorlage,
version,
abschnitte: { vorbereitung, konto },
schritte: { sicherung, notizSchritt, kontoAngelegt, gruppen },
pflichtIds: [sicherung.id, kontoAngelegt.id, gruppen.id],
};
}
/** Setzt die genannten Schritte auf ERLEDIGT – über den Dienst, wie die Oberfläche. */
async function erledigen(
laufId: string,
schrittIds: readonly string[],
benutzer: { readonly id: string; readonly rolle: Rolle },
): Promise<void> {
for (const vorlagenSchrittId of schrittIds) {
await schrittergebnisSetzen({ laufId, vorlagenSchrittId, zustand: SchrittZustand.ERLEDIGT }, benutzer);
}
}
async function aktionen(laufId: string): Promise<readonly string[]> {
const eintraege = await testPrisma.protokoll.findMany({
where: { laufId },
orderBy: { erstelltAm: "asc" },
});
return eintraege.map((eintrag) => eintrag.aktion);
}
describe.runIf(datenbankVerfuegbar)("Läufe", () => {
beforeEach(async () => {
await datenbankLeeren();
delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
});
describe("Anlegen und Zuweisen", () => {
// Akzeptanzkriterium 1
it("legt einen Lauf ohne Mitarbeiter und ohne Gerät an, schließt ihn ab und archiviert ihn", async () => {
const { admin, eigene } = await grundbestand();
const { version, pflichtIds } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
expect(lauf.status).toBe(LaufStatus.OFFEN);
expect(lauf.mitarbeiterId).toBeNull();
expect(lauf.geraetId).toBeNull();
expect(lauf.kundeId).toBe(eigene.id);
expect(lauf.erstelltVonId).toBe(admin.id);
// Die Ergebniszeilen entstehen beim Anlegen, damit der Fortschritt ab der
// ersten Sekunde stimmt.
expect(await testPrisma.schrittergebnis.count({ where: { laufId: lauf.id } })).toBe(4);
await erledigen(lauf.id, pflichtIds, admin);
const abgeschlossen = await laufAbschliessen(lauf.id, admin);
expect(abgeschlossen.status).toBe(LaufStatus.ABGESCHLOSSEN);
expect(abgeschlossen.abgeschlossenVonId).toBe(admin.id);
expect(abgeschlossen.abgeschlossenAm).not.toBeNull();
const archiviert = await laufArchivieren(lauf.id, admin);
expect(archiviert.status).toBe(LaufStatus.ARCHIVIERT);
expect(archiviert.archiviertVonId).toBe(admin.id);
expect(archiviert.archiviertAm).not.toBeNull();
expect(await aktionen(lauf.id)).toEqual(
expect.arrayContaining(["Lauf angelegt", "Lauf gestartet", "Lauf abgeschlossen", "Lauf archiviert"]),
);
});
// Akzeptanzkriterium 6
it("lässt gestartetAm leer, solange der Lauf nur OFFEN und zugewiesen ist", async () => {
const { admin, techniker } = await grundbestand();
const { version } = await vorlageMitVersion();
const lauf = await laufAnlegen(
{ vorlagenVersionId: version.id, zugewiesenAnId: techniker.id, ticket: "TICK-7" },
admin,
);
expect(lauf.status).toBe(LaufStatus.OFFEN);
expect(lauf.gestartetAm).toBeNull();
expect(lauf.zugewiesenAnId).toBe(techniker.id);
// Ansehen startet nichts: das Lesemodell rührt den Lauf nicht an, und auch
// nach dem Lesen der Kopfdaten steht gestartetAm noch leer.
const gelesen = await testPrisma.lauf.findUniqueOrThrow({ where: { id: lauf.id } });
expect(gelesen.gestartetAm).toBeNull();
expect(await aktionen(lauf.id)).toEqual(["Lauf angelegt"]);
});
it("startet den Lauf beim ersten Öffnen zur Bearbeitung und bleibt danach idempotent", async () => {
const { admin, techniker } = await grundbestand();
const { version } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
const erstes = await laufZurBearbeitungOeffnen(lauf.id, techniker);
expect(erstes.status).toBe(LaufStatus.IN_ARBEIT);
expect(erstes.gestartetAm).not.toBeNull();
const zweites = await laufZurBearbeitungOeffnen(lauf.id, techniker);
expect(zweites.gestartetAm?.getTime()).toBe(erstes.gestartetAm?.getTime());
expect((await aktionen(lauf.id)).filter((a) => a === "Lauf gestartet")).toHaveLength(1);
});
it("wirft einen abgeschlossenen oder archivierten Lauf beim Öffnen nicht zurück", async () => {
const { admin } = await grundbestand();
const { version, pflichtIds } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
await erledigen(lauf.id, pflichtIds, admin);
await laufAbschliessen(lauf.id, admin);
expect((await laufZurBearbeitungOeffnen(lauf.id, admin)).status).toBe(LaufStatus.ABGESCHLOSSEN);
await laufArchivieren(lauf.id, admin);
// Ein archivierter Lauf lässt sich aufschlagen, ohne dass das Öffnen wirft —
// sonst wäre er nicht mehr ansehbar.
expect((await laufZurBearbeitungOeffnen(lauf.id, admin)).status).toBe(LaufStatus.ARCHIVIERT);
});
it("schließt einen Lauf ohne Pflichtschritte direkt aus OFFEN ab, mit Startzeitpunkt", async () => {
const { admin } = await grundbestand();
const vorlage = await testPrisma.vorlage.create({ data: { name: "Patchday" } });
const version = await testPrisma.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF },
});
const abschnitt = await testPrisma.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: "Durchsicht", position: 0 },
});
await testPrisma.vorlagenSchritt.create({
data: { abschnittId: abschnitt.id, titel: "Kurz geschaut", pflicht: false, position: 0 },
});
await testPrisma.vorlagenVersion.update({
where: { id: version.id },
data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() },
});
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
const abgeschlossen = await laufAbschliessen(lauf.id, admin);
// Lauf_begonnen_mit_zeitpunkt in der Datenbank verlangt den Startzeitpunkt,
// und ein abgeschlossener Lauf ohne ihn sähe im Archiv wie ein Fehler aus.
expect(abgeschlossen.gestartetAm).not.toBeNull();
expect(abgeschlossen.status).toBe(LaufStatus.ABGESCHLOSSEN);
});
it("lehnt einen Entwurf und eine archivierte Vorlage als Grundlage ab", async () => {
const { admin } = await grundbestand();
const { vorlage, version } = await vorlageMitVersion();
const entwurf = await testPrisma.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 2, status: VersionStatus.ENTWURF },
});
await expect(laufAnlegen({ vorlagenVersionId: entwurf.id }, admin)).rejects.toThrow(LaufFehler);
await expect(laufAnlegen({ vorlagenVersionId: entwurf.id }, admin)).rejects.toThrow(
/veröffentlichte Version/,
);
await testPrisma.vorlage.update({ where: { id: vorlage.id }, data: { archiviert: true } });
await expect(laufAnlegen({ vorlagenVersionId: version.id }, admin)).rejects.toThrow(/archiviert/);
});
it("lehnt Person und Freitextname zugleich ab", async () => {
const { admin, eigene } = await grundbestand();
const { version } = await vorlageMitVersion();
const person = await testPrisma.mitarbeiter.create({
data: { kundeId: eigene.id, vorname: "Ada", nachname: "Lovelace", anzeigename: "Ada Lovelace" },
});
await expect(
laufAnlegen(
{ vorlagenVersionId: version.id, mitarbeiterId: person.id, mitarbeiterName: "Ada L." },
admin,
),
).rejects.toThrow(/beides zugleich/);
expect(await testPrisma.lauf.count()).toBe(0);
});
it("nimmt einen deaktivierten Mitarbeiter an – das Offboarding braucht ihn", async () => {
const { admin, eigene } = await grundbestand();
const { version } = await vorlageMitVersion();
const person = await testPrisma.mitarbeiter.create({
data: {
kundeId: eigene.id,
vorname: "Moritz",
nachname: "Geht",
anzeigename: "Moritz Geht",
aktiv: false,
},
});
const lauf = await laufAnlegen({ vorlagenVersionId: version.id, mitarbeiterId: person.id }, admin);
expect(lauf.mitarbeiterId).toBe(person.id);
});
it("lehnt ein archiviertes Gerät ab", async () => {
const { admin, eigene } = await grundbestand();
const { version } = await vorlageMitVersion();
const geraet = await testPrisma.geraet.create({
data: {
kundeId: eigene.id,
tagCode: "LZ-AAAAAA",
hostname: "PC-ALT-01",
archiviert: true,
registriertAm: new Date(),
},
});
await expect(
laufAnlegen({ vorlagenVersionId: version.id, geraetId: geraet.id }, admin),
).rejects.toThrow(/archiviert/);
});
it("weist eine Zuweisung an einen LESER und an ein deaktiviertes Konto ab", async () => {
const { admin, leser } = await grundbestand();
const { version } = await vorlageMitVersion();
const gesperrt = await testPrisma.benutzer.create({
data: { email: "weg@firma.example", name: "Wanda Weg", rolle: Rolle.TECHNIKER, aktiv: false },
});
await expect(
laufAnlegen({ vorlagenVersionId: version.id, zugewiesenAnId: leser.id }, admin),
).rejects.toThrow(/darf Läufe nicht bearbeiten/);
await expect(
laufAnlegen({ vorlagenVersionId: version.id, zugewiesenAnId: gesperrt.id }, admin),
).rejects.toThrow(/deaktiviert/);
});
});
describe("Variablen", () => {
it("nimmt gültige Werte auf und weist fehlende, unbekannte und falsche ab", async () => {
const { admin } = await grundbestand();
const { version } = await vorlageMitVersion({
variablen: [
{ schluessel: "os_version", beschriftung: "Betriebssystem", pflicht: true },
{
schluessel: "join_typ",
beschriftung: "Einbindung",
typ: VariablenTyp.AUSWAHL,
optionen: ["Active Directory", "Entra ID"],
},
{ schluessel: "speicher_gb", beschriftung: "Speicher in GB", typ: VariablenTyp.ZAHL },
],
});
await expect(laufAnlegen({ vorlagenVersionId: version.id }, admin)).rejects.toThrow(/„Betriebssystem“/);
await expect(
laufAnlegen(
{ vorlagenVersionId: version.id, variablen: { os_version: "Windows 11", tipfehler: "x" } },
admin,
),
).rejects.toThrow(/„tipfehler“/);
await expect(
laufAnlegen(
{ vorlagenVersionId: version.id, variablen: { os_version: "Windows 11", join_typ: "Novell" } },
admin,
),
).rejects.toThrow(/keine Auswahl/);
await expect(
laufAnlegen(
{ vorlagenVersionId: version.id, variablen: { os_version: "Windows 11", speicher_gb: "viel" } },
admin,
),
).rejects.toThrow(/erwartet eine Zahl/);
const lauf = await laufAnlegen(
{
vorlagenVersionId: version.id,
variablen: { os_version: " Windows 11 Pro ", join_typ: "Entra ID", speicher_gb: 32 },
},
admin,
);
expect(lauf.variablen).toEqual({
os_version: "Windows 11 Pro",
join_typ: "Entra ID",
speicher_gb: 32,
});
});
});
describe("Titelvorschlag", () => {
it("baut <Vorlage> – <Hostname oder Mitarbeiter> – <Datum>", () => {
const datum = new Date("2026-09-25T10:00:00Z");
expect(titelVorschlag({ vorlage: "Onboarding", geraet: "NB-ADA", datum })).toBe(
"Onboarding – NB-ADA – 25.09.2026",
);
// Der Hostname gewinnt: er steht auf dem Gerät, vor dem der Techniker sitzt.
expect(titelVorschlag({ vorlage: "Onboarding", geraet: "NB-ADA", person: "Ada Lovelace", datum })).toBe(
"Onboarding – NB-ADA – 25.09.2026",
);
expect(titelVorschlag({ vorlage: "Onboarding", person: "Ada Lovelace", datum })).toBe(
"Onboarding – Ada Lovelace – 25.09.2026",
);
// Fehlt der Bezug, fällt er weg statt als leere Stelle zu erscheinen.
expect(titelVorschlag({ vorlage: "Patchday", datum })).toBe("Patchday – 25.09.2026");
});
it("löst den Vorschlag auf, bevor der Lauf existiert", async () => {
const { admin, eigene } = await grundbestand();
const { version } = await vorlageMitVersion();
const geraet = await testPrisma.geraet.create({
data: {
kundeId: eigene.id,
tagCode: "LZ-BBBBBB",
hostname: "PC-VERTRIEB-07",
registriertAm: new Date(),
},
});
const vorschlag = await titelVorschlagErmitteln(
{ vorlagenVersionId: version.id, geraetId: geraet.id },
admin,
);
expect(vorschlag).toContain("PC-Neuinstallation");
expect(vorschlag).toContain("PC-VERTRIEB-07");
expect(await testPrisma.lauf.count()).toBe(0);
// Und das Anlegen ohne eigenen Titel setzt genau diesen Vorschlag ein.
const lauf = await laufAnlegen({ vorlagenVersionId: version.id, geraetId: geraet.id }, admin);
expect(lauf.titel).toBe(vorschlag);
});
});
describe("Abschließen", () => {
// Akzeptanzkriterium 2
it("scheitert, solange ein Pflichtschritt OFFEN ist, und nennt ihn", async () => {
const { admin } = await grundbestand();
const { version, schritte } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
await erledigen(lauf.id, [schritte.sicherung.id, schritte.kontoAngelegt.id], admin);
await expect(laufAbschliessen(lauf.id, admin)).rejects.toThrow(LaufFehler);
// Nicht „es fehlen noch Schritte“, sondern welche.
await expect(laufAbschliessen(lauf.id, admin)).rejects.toThrow(/Konto im AD · Gruppen zugewiesen/);
expect((await testPrisma.lauf.findUniqueOrThrow({ where: { id: lauf.id } })).status).toBe(
LaufStatus.IN_ARBEIT,
);
});
it("zählt FEHLGESCHLAGEN nicht als erledigt", async () => {
const { admin } = await grundbestand();
const { version, schritte, pflichtIds } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
await erledigen(
lauf.id,
pflichtIds.filter((id) => id !== schritte.gruppen.id),
admin,
);
await schrittergebnisSetzen(
{
laufId: lauf.id,
vorlagenSchrittId: schritte.gruppen.id,
zustand: SchrittZustand.FEHLGESCHLAGEN,
notiz: "Gruppenrichtlinie greift nicht, Ticket an das Netzwerkteam.",
},
admin,
);
await expect(laufAbschliessen(lauf.id, admin)).rejects.toThrow(/fehlgeschlagener Schritt/);
// NICHT_ZUTREFFEND mit Begründung macht den Weg frei.
await schrittergebnisSetzen(
{
laufId: lauf.id,
vorlagenSchrittId: schritte.gruppen.id,
zustand: SchrittZustand.NICHT_ZUTREFFEND,
notiz: "Der Kunde pflegt Gruppen selbst.",
},
admin,
);
expect((await laufAbschliessen(lauf.id, admin)).status).toBe(LaufStatus.ABGESCHLOSSEN);
});
it("lässt einen offenen Nicht-Pflichtschritt durchgehen", async () => {
const { admin } = await grundbestand();
const { version, pflichtIds } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
await erledigen(lauf.id, pflichtIds, admin);
expect((await laufAbschliessen(lauf.id, admin)).status).toBe(LaufStatus.ABGESCHLOSSEN);
});
it("ist beim zweiten Abschließen still – ein Doppelklick ist kein Fehler", async () => {
const { admin } = await grundbestand();
const { version, pflichtIds } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
await erledigen(lauf.id, pflichtIds, admin);
const erstes = await laufAbschliessen(lauf.id, admin);
const zweites = await laufAbschliessen(lauf.id, admin);
expect(zweites.abgeschlossenAm?.getTime()).toBe(erstes.abgeschlossenAm?.getTime());
expect((await aktionen(lauf.id)).filter((a) => a === "Lauf abgeschlossen")).toHaveLength(1);
});
it("hält zwei gleichzeitige Abschlüsse und ein Starten dazwischen aus", async () => {
const { admin, techniker } = await grundbestand();
const { version, pflichtIds } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
await erledigen(lauf.id, pflichtIds, admin);
// Zwei Techniker am selben Lauf: die Zeilensperre reiht die Schreibzugriffe
// auf, es entsteht genau ein Abschluss.
await Promise.all([
laufAbschliessen(lauf.id, admin),
laufAbschliessen(lauf.id, techniker),
laufZurBearbeitungOeffnen(lauf.id, techniker),
]);
const nachher = await testPrisma.lauf.findUniqueOrThrow({ where: { id: lauf.id } });
expect(nachher.status).toBe(LaufStatus.ABGESCHLOSSEN);
expect((await aktionen(lauf.id)).filter((a) => a === "Lauf abgeschlossen")).toHaveLength(1);
});
});
describe("Archiv", () => {
// Akzeptanzkriterium 4
it("lehnt an einem archivierten Lauf Schrittergebnis, Kommentar und Kopfdaten ab", async () => {
const { admin } = await grundbestand();
const { version, schritte, pflichtIds } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
await erledigen(lauf.id, pflichtIds, admin);
const ergebnis = await testPrisma.schrittergebnis.findFirstOrThrow({
where: { laufId: lauf.id, vorlagenSchrittId: schritte.sicherung.id },
});
await laufAbschliessen(lauf.id, admin);
await laufArchivieren(lauf.id, admin);
await expect(
schrittergebnisSetzen(
{
laufId: lauf.id,
vorlagenSchrittId: schritte.notizSchritt.id,
zustand: SchrittZustand.ERLEDIGT,
},
admin,
),
).rejects.toThrow(/archiviert/);
await expect(
kommentarHinzufuegen({ laufId: lauf.id, rumpf: "Noch ein Nachtrag." }, admin),
).rejects.toThrow(/archiviert/);
await expect(
kommentarHinzufuegen(
{ laufId: lauf.id, schrittergebnisId: ergebnis.id, rumpf: "Und hier auch." },
admin,
),
).rejects.toThrow(/archiviert/);
await expect(laufKopfdatenAendern(lauf.id, { ticket: "TICK-NEU" }, admin)).rejects.toThrow(
/archiviert/,
);
await expect(laufMitAdKontoVerknuepfen(lauf.id, { upn: "ada@firma.local" }, admin)).rejects.toThrow(
/archiviert/,
);
// Und es ist wirklich nichts angekommen.
const unberuehrt = await testPrisma.schrittergebnis.findFirstOrThrow({
where: { laufId: lauf.id, vorlagenSchrittId: schritte.notizSchritt.id },
});
expect(unberuehrt.zustand).toBe(SchrittZustand.OFFEN);
expect(await testPrisma.kommentar.count({ where: { laufId: lauf.id } })).toBe(0);
});
// Akzeptanzkriterium 5
it("holt nur ein ADMIN einen archivierten Lauf zurück, mit Protokolleintrag", async () => {
const { admin, techniker } = await grundbestand();
const { version, pflichtIds } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
await erledigen(lauf.id, pflichtIds, admin);
await laufAbschliessen(lauf.id, admin);
await laufArchivieren(lauf.id, admin);
await expect(laufZurueckholen(lauf.id, techniker)).rejects.toThrow(/Administrator/);
expect((await testPrisma.lauf.findUniqueOrThrow({ where: { id: lauf.id } })).status).toBe(
LaufStatus.ARCHIVIERT,
);
const zurueck = await laufZurueckholen(lauf.id, admin);
expect(zurueck.status).toBe(LaufStatus.ABGESCHLOSSEN);
expect(zurueck.archiviertAm).toBeNull();
expect(zurueck.archiviertVonId).toBeNull();
const eintrag = await testPrisma.protokoll.findFirstOrThrow({
where: { laufId: lauf.id, aktion: "Lauf aus dem Archiv zurückgeholt" },
});
expect(eintrag.benutzerId).toBe(admin.id);
// Und danach ist der Lauf wieder änderbar.
const geaendert = await laufKopfdatenAendern(lauf.id, { ticket: "TICK-9" }, admin);
expect(geaendert.ticket).toBe("TICK-9");
});
it("archiviert nur einen abgeschlossenen Lauf und bleibt beim zweiten Mal still", async () => {
const { admin } = await grundbestand();
const { version, pflichtIds } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
await expect(laufArchivieren(lauf.id, admin)).rejects.toThrow(/Erst abschließen/);
await erledigen(lauf.id, pflichtIds, admin);
await laufAbschliessen(lauf.id, admin);
const erstes = await laufArchivieren(lauf.id, admin);
const zweites = await laufArchivieren(lauf.id, admin);
expect(zweites.archiviertAm?.getTime()).toBe(erstes.archiviertAm?.getTime());
expect((await aktionen(lauf.id)).filter((a) => a === "Lauf archiviert")).toHaveLength(1);
});
it("hält den Schreibschutz an genau einer Stelle fest", () => {
// Ein abgeschlossener Lauf bleibt änderbar – nachgetragene Notizen und
// Kommentare sind der Normalfall. Erst das Archivieren sperrt.
for (const status of [LaufStatus.OFFEN, LaufStatus.IN_ARBEIT, LaufStatus.ABGESCHLOSSEN]) {
expect(() => laufMussVeraenderbarSein({ status, titel: "Ein Lauf" })).not.toThrow();
}
expect(() => laufMussVeraenderbarSein({ status: LaufStatus.ARCHIVIERT, titel: "Ein Lauf" })).toThrow(
LaufFehler,
);
});
});
describe("Bindung an die Version", () => {
it("bleibt an seiner Version, auch wenn danach eine neue veröffentlicht wird", async () => {
const { admin } = await grundbestand();
const { vorlage, version, pflichtIds } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
// Version 2 mit einem zusätzlichen Pflichtschritt.
const zweite = await testPrisma.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 2, status: VersionStatus.ENTWURF },
});
const abschnitt = await testPrisma.vorlagenAbschnitt.create({
data: { versionId: zweite.id, titel: "Neu", position: 0 },
});
await testPrisma.vorlagenSchritt.create({
data: { abschnittId: abschnitt.id, titel: "BitLocker geprüft", pflicht: true, position: 0 },
});
await testPrisma.vorlagenVersion.update({
where: { id: zweite.id },
data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() },
});
// Der laufende Lauf kennt den neuen Pflichtschritt nicht: die drei
// Pflichtschritte aus Version 1 genügen zum Abschluss.
await erledigen(lauf.id, pflichtIds, admin);
const abgeschlossen = await laufAbschliessen(lauf.id, admin);
expect(abgeschlossen.vorlagenVersionId).toBe(version.id);
expect(await testPrisma.schrittergebnis.count({ where: { laufId: lauf.id } })).toBe(4);
// Und es gibt keinen Weg, ihn auf die neue Version zu heben.
await expect(laufKopfdatenAendern(lauf.id, { vorlagenVersionId: zweite.id }, admin)).rejects.toThrow(
/gestartet wurde/,
);
});
});
describe("Kopfdaten", () => {
it("ändert nur, was sich wirklich ändert, und protokolliert es", async () => {
const { admin, techniker } = await grundbestand();
const { version } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id, ticket: "TICK-1" }, admin);
const unveraendert = await laufKopfdatenAendern(lauf.id, { ticket: "TICK-1" }, admin);
expect(unveraendert.geaendertAm.getTime()).toBe(lauf.geaendertAm.getTime());
expect(await aktionen(lauf.id)).toEqual(["Lauf angelegt"]);
const faellig = new Date("2026-10-01T08:00:00Z");
const geaendert = await laufKopfdatenAendern(
lauf.id,
{ ticket: "TICK-2", zugewiesenAnId: techniker.id, faelligAm: faellig, titel: "Neuer Titel" },
admin,
);
expect(geaendert.ticket).toBe("TICK-2");
expect(geaendert.zugewiesenAnId).toBe(techniker.id);
expect(geaendert.faelligAm?.getTime()).toBe(faellig.getTime());
expect(geaendert.titel).toBe("Neuer Titel");
const eintrag = await testPrisma.protokoll.findFirstOrThrow({
where: { laufId: lauf.id, entitaet: "Lauf", aktion: { contains: "geändert" } },
});
expect(eintrag.vorher).toMatchObject({ Ticket: "TICK-1" });
expect(eintrag.nachher).toMatchObject({ Ticket: "TICK-2", Titel: "Neuer Titel" });
});
it("prüft geänderte Variablen erneut und meldet nur echte Änderungen", async () => {
const { admin } = await grundbestand();
const { version } = await vorlageMitVersion({
variablen: [
{ schluessel: "os_version", beschriftung: "Betriebssystem", pflicht: true },
{ schluessel: "notiz", beschriftung: "Bemerkung" },
],
});
const lauf = await laufAnlegen(
{ vorlagenVersionId: version.id, variablen: { os_version: "Windows 11", notiz: "eilig" } },
admin,
);
// Dieselben Werte in anderer Reihenfolge sind keine Änderung.
const gleich = await laufKopfdatenAendern(
lauf.id,
{ variablen: { notiz: "eilig", os_version: "Windows 11" } },
admin,
);
expect(gleich.geaendertAm.getTime()).toBe(lauf.geaendertAm.getTime());
expect(await aktionen(lauf.id)).toEqual(["Lauf angelegt"]);
// Eine Pflichtvariable lässt sich auch nachträglich nicht leeren.
await expect(laufKopfdatenAendern(lauf.id, { variablen: { notiz: "eilig" } }, admin)).rejects.toThrow(
/„Betriebssystem“/,
);
const geaendert = await laufKopfdatenAendern(
lauf.id,
{ variablen: { os_version: "Windows 11 Pro", notiz: "eilig" } },
admin,
);
expect(geaendert.variablen).toEqual({ os_version: "Windows 11 Pro", notiz: "eilig" });
expect(await aktionen(lauf.id)).toContain("Variablen geändert");
});
it("lehnt einen leeren Titel ab", async () => {
const { admin } = await grundbestand();
const { version } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
await expect(laufKopfdatenAendern(lauf.id, { titel: " " }, admin)).rejects.toThrow(/Titel/);
});
});
describe("Mit AD-Konto verknüpfen", () => {
it("verbindet einen Freitextnamen über den UPN und behält den alten Namen im Protokoll", async () => {
const { admin, eigene } = await grundbestand();
const { version } = await vorlageMitVersion();
const lauf = await laufAnlegen(
{ vorlagenVersionId: version.id, mitarbeiterName: "Ada Lovelace (neu)" },
admin,
);
const person = await testPrisma.mitarbeiter.create({
data: {
kundeId: eigene.id,
vorname: "Ada",
nachname: "Lovelace",
anzeigename: "Ada Lovelace",
upn: "ada.lovelace@firma.local",
},
});
const verknuepft = await laufMitAdKontoVerknuepfen(lauf.id, { upn: "ADA.LOVELACE@firma.local" }, admin);
expect(verknuepft.mitarbeiterId).toBe(person.id);
// Die Bedingung Lauf_mitarbeiter_eindeutig schließt beides zugleich aus.
expect(verknuepft.mitarbeiterName).toBeNull();
const eintrag = await testPrisma.protokoll.findFirstOrThrow({
where: { laufId: lauf.id, aktion: "Mit AD-Konto verknüpft" },
});
expect(eintrag.vorher).toMatchObject({ mitarbeiterName: "Ada Lovelace (neu)" });
expect(eintrag.nachher).toMatchObject({ anzeigename: "Ada Lovelace" });
});
it("lehnt ein zweites Konto und einen unbekannten UPN ab", async () => {
const { admin, eigene } = await grundbestand();
const { version } = await vorlageMitVersion();
const person = await testPrisma.mitarbeiter.create({
data: {
kundeId: eigene.id,
vorname: "Ada",
nachname: "Lovelace",
anzeigename: "Ada Lovelace",
upn: "ada@firma.local",
},
});
const lauf = await laufAnlegen({ vorlagenVersionId: version.id, mitarbeiterId: person.id }, admin);
await expect(laufMitAdKontoVerknuepfen(lauf.id, { mitarbeiterId: person.id }, admin)).rejects.toThrow(
/hängt schon eine Person/,
);
const zweiter = await laufAnlegen(
{ vorlagenVersionId: version.id, mitarbeiterName: "Niemand Bekannt" },
admin,
);
await expect(
laufMitAdKontoVerknuepfen(zweiter.id, { upn: "gibt.es.nicht@firma.local" }, admin),
).rejects.toThrow(/kein Konto/);
});
});
describe("Mandantengrenze", () => {
it("verwirft im Modus INTERN eine mitgeschickte kundeId", async () => {
const { admin, eigene } = await grundbestand();
const { version } = await vorlageMitVersion();
const fremder = await testPrisma.kunde.create({ data: { name: "Fremd GmbH", kuerzel: "FREMD" } });
const lauf = await laufAnlegen({ vorlagenVersionId: version.id, kundeId: fremder.id }, admin);
expect(lauf.kundeId).toBe(eigene.id);
});
it("lehnt Gerät, Person und Abteilung eines fremden Kunden ab", async () => {
const { admin } = await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
const kundeA = await testPrisma.kunde.create({ data: { name: "Kunde A", kuerzel: "KA" } });
const kundeB = await testPrisma.kunde.create({ data: { name: "Kunde B", kuerzel: "KB" } });
const { version } = await vorlageMitVersion();
const geraetB = await testPrisma.geraet.create({
data: {
kundeId: kundeB.id,
tagCode: "LZ-CCCCCC",
hostname: "PC-B-01",
registriertAm: new Date(),
},
});
const personB = await testPrisma.mitarbeiter.create({
data: { kundeId: kundeB.id, vorname: "Bea", nachname: "Bee", anzeigename: "Bea Bee" },
});
const abteilungB = await testPrisma.abteilung.create({
data: { kundeId: kundeB.id, name: "Buchhaltung" },
});
for (const eingabe of [
{ geraetId: geraetB.id },
{ mitarbeiterId: personB.id },
{ abteilungId: abteilungB.id },
]) {
await expect(
laufAnlegen({ vorlagenVersionId: version.id, kundeId: kundeA.id, ...eingabe }, admin),
).rejects.toThrow(/anderen Kunden/);
}
expect(await testPrisma.lauf.count()).toBe(0);
});
it("lehnt eine kundenspezifische Vorlage bei einem anderen Kunden ab", async () => {
const { admin } = await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
const kundeA = await testPrisma.kunde.create({ data: { name: "Kunde A", kuerzel: "KA" } });
const kundeB = await testPrisma.kunde.create({ data: { name: "Kunde B", kuerzel: "KB" } });
const { version } = await vorlageMitVersion({ kundeId: kundeB.id, name: "Nur für B" });
await expect(laufAnlegen({ vorlagenVersionId: version.id, kundeId: kundeA.id }, admin)).rejects.toThrow(
/anderen Kunden/,
);
expect((await laufAnlegen({ vorlagenVersionId: version.id, kundeId: kundeB.id }, admin)).kundeId).toBe(
kundeB.id,
);
});
it("lässt einen Techniker nicht an den Lauf eines fremden Kunden", async () => {
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "laufzettel-";
const { admin, techniker } = await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
const kundeA = await testPrisma.kunde.create({ data: { name: "Kunde A", kuerzel: "KA" } });
const kundeB = await testPrisma.kunde.create({ data: { name: "Kunde B", kuerzel: "KB" } });
await testPrisma.benutzerKunde.create({
data: { benutzerId: techniker.id, kundeId: kundeB.id },
});
const { version } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id, kundeId: kundeA.id }, admin);
// Kennung allein öffnet keinen fremden Mandanten – in jeder Funktion.
await expect(laufZurBearbeitungOeffnen(lauf.id, techniker)).rejects.toThrow(/Berechtigung/);
await expect(laufKopfdatenAendern(lauf.id, { ticket: "X" }, techniker)).rejects.toThrow(/Berechtigung/);
await expect(laufAbschliessen(lauf.id, techniker)).rejects.toThrow(/Berechtigung/);
await expect(kommentarHinzufuegen({ laufId: lauf.id, rumpf: "Hallo" }, techniker)).rejects.toThrow(
/Berechtigung/,
);
});
});
});
+2 -2
View File
@@ -9,7 +9,7 @@ import { Markdown } from "@/components/markdown";
* geoeffnet. Rohes HTML darin waere fremder Code in fremden Browsern; ueber den
* JSON-Import kaeme er sogar von ausserhalb des Hauses. `react-markdown` laesst
* HTML von sich aus fallen — dieser Test haelt fest, dass niemand spaeter
* `rehype-raw` dazustellt, weil „der Kunde Tabellen in HTML schreibt".
* `rehype-raw` dazustellt, weil „der Kunde Tabellen in HTML schreibt“.
*
* Gerendert wird mit `react-dom/server`, damit der Test ohne DOM-Umgebung laeuft.
*/
@@ -40,7 +40,7 @@ describe("Markdown im Hilfetext", () => {
});
it("laesst weder Bilder mit Ereignisattribut noch eingebettete Rahmen durch", () => {
// Geprueft wird die *Auszeichnung*, nicht das Wort: „onerror" steht danach
// Geprueft wird die *Auszeichnung*, nicht das Wort: „onerror“ steht danach
// noch im Text, aber maskiert – es wird angezeigt und nicht ausgefuehrt.
const html = alsHtml('<img src=x onerror="alert(1)"> <iframe src="https://fremd.example"></iframe>');
expect(html).not.toContain("<img");
+27 -6
View File
@@ -161,15 +161,36 @@ describe("Generiertes Passwort", () => {
expect(platzhalterErsetzen("Hallo {{vorname}}", WERTE).mitPasswort).toBe(false);
});
it("erzeugt für zwei Vorkommen im selben Text zwei verschiedene Passwörter", () => {
it("setzt in einem Text überall dasselbe Passwort ein", () => {
/*
* Fachlich fragwuerdig, aber die ehrliche Folge der Regel „bei jeder Anzeige
* neu“: wer den Platzhalter zweimal in einen Befehl schreibt, meint
* vermutlich dasselbe Passwort. Dass es zwei sind, faellt beim Lesen auf —
* eines still zu speichern und wiederzuverwenden fiele nicht auf.
* Der Fall, den der Review zu #013 gefunden hat: ein `New-ADUser` setzt das
* Passwort und teilt es in derselben Zeile mit. Zwei verschiedene hiessen,
* dass das Konto A bekommt und der Benutzer B erfaehrt — ein Konto, in das
* niemand hineinkommt, und niemand sieht warum. Frueher stand hier ein Test,
* der genau das festschrieb; er war falsch.
*/
const ergebnis = platzhalterErsetzen("{{generiertes_passwort}} und {{generiertes_passwort}}", {});
const [erstes, zweites] = ergebnis.text.split(" und ");
expect(erstes).not.toBe(zweites);
expect(erstes).toBe(zweites);
expect(erstes).toHaveLength(20);
});
it("erzeugt bei jedem Aufruf ein neues", () => {
// „Bei jeder Anzeige neu“ bleibt: dieselbe Zeile, zweimal gefuellt, ergibt
// zwei Passwoerter. Sonst waere es gespeichert, und genau das darf es nicht.
const einmal = platzhalterErsetzen("{{generiertes_passwort}}", {}).text;
const nochmal = platzhalterErsetzen("{{generiertes_passwort}}", {}).text;
expect(einmal).not.toBe(nochmal);
});
it("nimmt das Passwort des Seitenaufbaus, wenn eines hereingereicht wird", () => {
// So haelt ein Lauf ueber alle seine Befehlsschritte hinweg dasselbe
// Passwort — der Schritt „Konto anlegen“ und der Schritt „Passwort
// uebergeben“ meinen dasselbe.
const eines = platzhalterErsetzen("{{generiertes_passwort}}", {}, { art: "anzeige" }).text;
const erster = platzhalterErsetzen("a {{generiertes_passwort}}", {}, { art: "anzeige" }, eines).text;
const zweiter = platzhalterErsetzen("b {{generiertes_passwort}}", {}, { art: "anzeige" }, eines).text;
expect(erster).toBe(`a ${eines}`);
expect(zweiter).toBe(`b ${eines}`);
});
});
+553
View File
@@ -0,0 +1,553 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
Betriebsmodus,
LaufStatus,
Rolle,
SchrittTyp,
SchrittZustand,
VersionStatus,
} from "@/generated/prisma/enums";
import { LaufFehler, laufAbschliessen, laufAnlegen } from "@/server/services/laeufe";
import { abschnittNichtZutreffend, schrittergebnisSetzen } from "@/server/services/schrittergebnisse";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Schrittergebnisse (#013).
*
* Zwei Regeln stehen hier im Mittelpunkt. Die Notizpflicht bei
* `NICHT_ZUTREFFEND` und `FEHLGESCHLAGEN` — ohne sie steht im Archiv ein
* übersprungener Schritt ohne Begründung, und das ist genau die Lücke, die ein
* Archiv wertlos macht. Und die Abschnitts-Aktion, die achtzehn Haken mit
* derselben Begründung erledigt, ohne die Arbeit zu löschen, die jemand schon
* getan hat.
*/
async function grundbestand() {
const eigene = await testPrisma.kunde.create({
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({ data: { id: "global", modus: Betriebsmodus.INTERN } });
const adminZeile = await testPrisma.benutzer.create({
data: { email: "admin@firma.example", name: "Alex Admin", rolle: Rolle.ADMIN },
});
const technikerZeile = await testPrisma.benutzer.create({
data: { email: "technik@firma.example", name: "Tina Technik", rolle: Rolle.TECHNIKER },
});
return {
eigene,
admin: { id: adminZeile.id, rolle: Rolle.ADMIN },
techniker: { id: technikerZeile.id, rolle: Rolle.TECHNIKER },
};
}
/**
* Eine veröffentlichte Version mit zwei Abschnitten: „Vorbereitung“ mit einem
* Pflicht-Haken und einem Zahlenschritt, „Konto im AD“ mit zwei Pflichtschritten
* — der Abschnitt, der im Onboarding je nach Kunde gar nicht zutrifft.
*/
async function vorlageMitVersion() {
const vorlage = await testPrisma.vorlage.create({ data: { name: "Onboarding" } });
const version = await testPrisma.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF },
});
const vorbereitung = await testPrisma.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: "Vorbereitung", position: 0 },
});
const sicherung = await testPrisma.vorlagenSchritt.create({
data: { abschnittId: vorbereitung.id, titel: "Datensicherung geprüft", pflicht: true, position: 0 },
});
const speicher = await testPrisma.vorlagenSchritt.create({
data: {
abschnittId: vorbereitung.id,
titel: "Freier Speicher in GB",
typ: SchrittTyp.ZAHL,
pflicht: false,
position: 1,
konfig: { min: 20, max: 2000, einheit: "GB" },
},
});
const konto = await testPrisma.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: "Konto im AD", position: 1 },
});
const kontoAngelegt = await testPrisma.vorlagenSchritt.create({
data: { abschnittId: konto.id, titel: "Konto angelegt", pflicht: true, position: 0 },
});
const gruppen = await testPrisma.vorlagenSchritt.create({
data: { abschnittId: konto.id, titel: "Gruppen zugewiesen", pflicht: true, position: 1 },
});
await testPrisma.vorlagenVersion.update({
where: { id: version.id },
data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() },
});
return {
version,
abschnitte: { vorbereitung, konto },
schritte: { sicherung, speicher, kontoAngelegt, gruppen },
pflichtIds: [sicherung.id, kontoAngelegt.id, gruppen.id],
};
}
async function eintraege(laufId: string, entitaet: string): Promise<readonly string[]> {
const zeilen = await testPrisma.protokoll.findMany({
where: { laufId, entitaet },
orderBy: { erstelltAm: "asc" },
});
return zeilen.map((zeile) => zeile.aktion);
}
describe.runIf(datenbankVerfuegbar)("Schrittergebnisse", () => {
beforeEach(async () => {
await datenbankLeeren();
delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
});
describe("Notizpflicht", () => {
// Akzeptanzkriterium 3
it("lehnt NICHT_ZUTREFFEND ohne Notiz ab und nimmt es mit Notiz an", async () => {
const { admin } = await grundbestand();
const { version, schritte } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
await expect(
schrittergebnisSetzen(
{
laufId: lauf.id,
vorlagenSchrittId: schritte.gruppen.id,
zustand: SchrittZustand.NICHT_ZUTREFFEND,
},
admin,
),
).rejects.toThrow(LaufFehler);
await expect(
schrittergebnisSetzen(
{
laufId: lauf.id,
vorlagenSchrittId: schritte.gruppen.id,
zustand: SchrittZustand.NICHT_ZUTREFFEND,
notiz: " ",
},
admin,
),
).rejects.toThrow(/Begründung/);
const ergebnis = await schrittergebnisSetzen(
{
laufId: lauf.id,
vorlagenSchrittId: schritte.gruppen.id,
zustand: SchrittZustand.NICHT_ZUTREFFEND,
notiz: "Der Kunde pflegt Gruppen selbst.",
},
admin,
);
expect(ergebnis.zustand).toBe(SchrittZustand.NICHT_ZUTREFFEND);
expect(ergebnis.notiz).toBe("Der Kunde pflegt Gruppen selbst.");
expect(ergebnis.aktualisiertVonId).toBe(admin.id);
});
it("verlangt auch bei FEHLGESCHLAGEN eine Notiz", async () => {
const { admin } = await grundbestand();
const { version, schritte } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
await expect(
schrittergebnisSetzen(
{
laufId: lauf.id,
vorlagenSchrittId: schritte.kontoAngelegt.id,
zustand: SchrittZustand.FEHLGESCHLAGEN,
},
admin,
),
).rejects.toThrow(/Notiz/);
});
it("prüft den Zustand danach: eine vorhandene Notiz genügt, das Leeren nicht", async () => {
const { admin } = await grundbestand();
const { version, schritte } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
await schrittergebnisSetzen(
{
laufId: lauf.id,
vorlagenSchrittId: schritte.gruppen.id,
zustand: SchrittZustand.FEHLGESCHLAGEN,
notiz: "Gruppenrichtlinie greift nicht.",
},
admin,
);
// Wechsel des Zustands ohne Notiz-Feld: die vorhandene Begründung bleibt.
const gewechselt = await schrittergebnisSetzen(
{
laufId: lauf.id,
vorlagenSchrittId: schritte.gruppen.id,
zustand: SchrittZustand.NICHT_ZUTREFFEND,
},
admin,
);
expect(gewechselt.notiz).toBe("Gruppenrichtlinie greift nicht.");
// Das Leeren im selben Zug fällt auf die Pflicht.
await expect(
schrittergebnisSetzen(
{
laufId: lauf.id,
vorlagenSchrittId: schritte.gruppen.id,
zustand: SchrittZustand.NICHT_ZUTREFFEND,
notiz: null,
},
admin,
),
).rejects.toThrow(/Begründung/);
});
});
describe("Setzen", () => {
it("startet den Lauf beim ersten Schrittergebnis", async () => {
const { admin, techniker } = await grundbestand();
const { version, schritte } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
expect(lauf.gestartetAm).toBeNull();
await schrittergebnisSetzen(
{ laufId: lauf.id, vorlagenSchrittId: schritte.sicherung.id, zustand: SchrittZustand.ERLEDIGT },
techniker,
);
const nachher = await testPrisma.lauf.findUniqueOrThrow({ where: { id: lauf.id } });
expect(nachher.status).toBe(LaufStatus.IN_ARBEIT);
expect(nachher.gestartetAm).not.toBeNull();
expect(await eintraege(lauf.id, "Lauf")).toContain("Lauf gestartet");
});
// Akzeptanzkriterium 7
it("schreibt jede Änderung ins Protokoll – und eine Wiederholung nicht", async () => {
const { admin } = await grundbestand();
const { version, schritte } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
await schrittergebnisSetzen(
{ laufId: lauf.id, vorlagenSchrittId: schritte.sicherung.id, zustand: SchrittZustand.ERLEDIGT },
admin,
);
await schrittergebnisSetzen(
{ laufId: lauf.id, vorlagenSchrittId: schritte.sicherung.id, zustand: SchrittZustand.OFFEN },
admin,
);
// Zweimal dasselbe: ein Doppelklick soll keinen zweiten Eintrag ergeben.
await schrittergebnisSetzen(
{ laufId: lauf.id, vorlagenSchrittId: schritte.sicherung.id, zustand: SchrittZustand.OFFEN },
admin,
);
expect(await eintraege(lauf.id, "Schrittergebnis")).toEqual([
"Schritt erledigt",
"Schritt zurückgesetzt",
]);
const letzter = await testPrisma.protokoll.findFirstOrThrow({
where: { laufId: lauf.id, aktion: "Schritt zurückgesetzt" },
});
expect(letzter.vorher).toMatchObject({ zustand: SchrittZustand.ERLEDIGT });
expect(letzter.nachher).toMatchObject({
schritt: "Datensicherung geprüft",
pflicht: true,
zustand: SchrittZustand.OFFEN,
});
});
it("behält den Wert beim Zustandswechsel und löscht ihn nur auf Ansage", async () => {
const { admin } = await grundbestand();
const { version, schritte } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
const mitWert = await schrittergebnisSetzen(
{
laufId: lauf.id,
vorlagenSchrittId: schritte.speicher.id,
zustand: SchrittZustand.ERLEDIGT,
wert: { zahl: 512 },
},
admin,
);
expect(mitWert.wert).toEqual({ zahl: 512 });
const ohneWertfeld = await schrittergebnisSetzen(
{ laufId: lauf.id, vorlagenSchrittId: schritte.speicher.id, zustand: SchrittZustand.OFFEN },
admin,
);
expect(ohneWertfeld.wert).toEqual({ zahl: 512 });
const geleert = await schrittergebnisSetzen(
{
laufId: lauf.id,
vorlagenSchrittId: schritte.speicher.id,
zustand: SchrittZustand.OFFEN,
wert: null,
},
admin,
);
expect(geleert.wert).toBeNull();
});
it("blockiert eine Zahl außerhalb von Min und Max nicht", async () => {
const { admin } = await grundbestand();
const { version, schritte } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
// Der Auftrag sagt ausdrücklich: markieren, nicht blockieren. Ein Gerät mit
// 4 GB frei ist eine Tatsache, und der Dienst hat sie aufzunehmen.
const ergebnis = await schrittergebnisSetzen(
{
laufId: lauf.id,
vorlagenSchrittId: schritte.speicher.id,
zustand: SchrittZustand.ERLEDIGT,
wert: { zahl: 4 },
},
admin,
);
expect(ergebnis.wert).toEqual({ zahl: 4 });
});
it("lehnt einen Schritt ab, der zu diesem Lauf nicht gehört", async () => {
const { admin } = await grundbestand();
const { version } = await vorlageMitVersion();
const fremde = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
// Ein Schritt aus einer anderen Version – in der Abarbeitungsansicht
// tauchte er nie auf, im Archiv aber schon.
await expect(
schrittergebnisSetzen(
{
laufId: lauf.id,
vorlagenSchrittId: fremde.schritte.sicherung.id,
zustand: SchrittZustand.ERLEDIGT,
},
admin,
),
).rejects.toThrow(/gehört nicht zu diesem Lauf/);
await expect(
schrittergebnisSetzen(
{ laufId: lauf.id, vorlagenSchrittId: "gibt-es-nicht", zustand: SchrittZustand.ERLEDIGT },
admin,
),
).rejects.toThrow(/gehört nicht zu diesem Lauf/);
});
it("nimmt den Abschluss zurück, wenn ein Pflichtschritt wieder offen ist", async () => {
const { admin } = await grundbestand();
const { version, schritte, pflichtIds } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
for (const vorlagenSchrittId of pflichtIds) {
await schrittergebnisSetzen(
{ laufId: lauf.id, vorlagenSchrittId, zustand: SchrittZustand.ERLEDIGT },
admin,
);
}
await laufAbschliessen(lauf.id, admin);
// Ein abgeschlossener Lauf bleibt änderbar – aber „abgeschlossen“ darf
// danach nicht behaupten, alle Pflichtschritte seien erledigt.
await schrittergebnisSetzen(
{ laufId: lauf.id, vorlagenSchrittId: schritte.gruppen.id, zustand: SchrittZustand.OFFEN },
admin,
);
const nachher = await testPrisma.lauf.findUniqueOrThrow({ where: { id: lauf.id } });
expect(nachher.status).toBe(LaufStatus.IN_ARBEIT);
expect(nachher.abgeschlossenAm).toBeNull();
expect(nachher.abgeschlossenVonId).toBeNull();
expect(await eintraege(lauf.id, "Lauf")).toContain("Abschluss zurückgenommen");
});
it("hält zwei Techniker am selben Lauf aus", async () => {
const { admin, techniker } = await grundbestand();
const { version, schritte } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
await Promise.all([
schrittergebnisSetzen(
{ laufId: lauf.id, vorlagenSchrittId: schritte.sicherung.id, zustand: SchrittZustand.ERLEDIGT },
admin,
),
schrittergebnisSetzen(
{
laufId: lauf.id,
vorlagenSchrittId: schritte.kontoAngelegt.id,
zustand: SchrittZustand.ERLEDIGT,
},
techniker,
),
]);
const erledigt = await testPrisma.schrittergebnis.count({
where: { laufId: lauf.id, zustand: SchrittZustand.ERLEDIGT },
});
expect(erledigt).toBe(2);
// Und nur ein „Lauf gestartet“, obwohl beide gleichzeitig angefangen haben.
expect((await eintraege(lauf.id, "Lauf")).filter((a) => a === "Lauf gestartet")).toHaveLength(1);
});
it("hält zwei gleichzeitige Änderungen am selben Schritt aus", async () => {
const { admin, techniker } = await grundbestand();
const { version, schritte } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
await Promise.all([
schrittergebnisSetzen(
{ laufId: lauf.id, vorlagenSchrittId: schritte.sicherung.id, zustand: SchrittZustand.ERLEDIGT },
admin,
),
schrittergebnisSetzen(
{
laufId: lauf.id,
vorlagenSchrittId: schritte.sicherung.id,
zustand: SchrittZustand.FEHLGESCHLAGEN,
notiz: "Sicherung schlug fehl.",
},
techniker,
),
]);
// Eine Zeile, nicht zwei: der eindeutige Index (laufId, vorlagenSchrittId)
// und die Zeilensperre halten zusammen.
const zeilen = await testPrisma.schrittergebnis.findMany({
where: { laufId: lauf.id, vorlagenSchrittId: schritte.sicherung.id },
});
expect(zeilen).toHaveLength(1);
expect([SchrittZustand.ERLEDIGT, SchrittZustand.FEHLGESCHLAGEN]).toContain(zeilen[0]?.zustand);
});
});
describe("Abschnitt als nicht zutreffend", () => {
// Akzeptanzkriterium 8
it("setzt alle Schritte des Abschnitts mit derselben Notiz", async () => {
const { admin } = await grundbestand();
const { version, abschnitte, schritte } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
const ergebnis = await abschnittNichtZutreffend(
{
laufId: lauf.id,
abschnittId: abschnitte.konto.id,
notiz: "Der Kunde verwaltet Konten selbst.",
},
admin,
);
expect(ergebnis.gesetzt).toBe(2);
expect(ergebnis.uebersprungen).toEqual([]);
const zeilen = await testPrisma.schrittergebnis.findMany({
where: {
laufId: lauf.id,
vorlagenSchrittId: { in: [schritte.kontoAngelegt.id, schritte.gruppen.id] },
},
});
expect(zeilen).toHaveLength(2);
for (const zeile of zeilen) {
expect(zeile.zustand).toBe(SchrittZustand.NICHT_ZUTREFFEND);
expect(zeile.notiz).toBe("Der Kunde verwaltet Konten selbst.");
}
// Der andere Abschnitt bleibt unberührt.
const sicherung = await testPrisma.schrittergebnis.findFirstOrThrow({
where: { laufId: lauf.id, vorlagenSchrittId: schritte.sicherung.id },
});
expect(sicherung.zustand).toBe(SchrittZustand.OFFEN);
// Je geänderter Schritt ein Protokolleintrag.
expect(await eintraege(lauf.id, "Schrittergebnis")).toEqual([
"Abschnitt als nicht zutreffend eingetragen",
"Abschnitt als nicht zutreffend eingetragen",
]);
});
it("lässt erledigte Schritte erledigt und meldet sie", async () => {
const { admin } = await grundbestand();
const { version, abschnitte, schritte } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
await schrittergebnisSetzen(
{
laufId: lauf.id,
vorlagenSchrittId: schritte.kontoAngelegt.id,
zustand: SchrittZustand.ERLEDIGT,
},
admin,
);
await schrittergebnisSetzen(
{
laufId: lauf.id,
vorlagenSchrittId: schritte.gruppen.id,
zustand: SchrittZustand.FEHLGESCHLAGEN,
notiz: "Gruppen fehlen im AD.",
},
admin,
);
const ergebnis = await abschnittNichtZutreffend(
{ laufId: lauf.id, abschnittId: abschnitte.konto.id, notiz: "Kunde macht das selbst." },
admin,
);
// „Erledigt“ ist eine Tatsache mit Namen und Zeitstempel; „nicht
// zutreffend“ darüberzuschreiben wäre deren Verlust.
expect(ergebnis.uebersprungen).toEqual(["Konto angelegt"]);
expect(ergebnis.gesetzt).toBe(1);
const angelegt = await testPrisma.schrittergebnis.findFirstOrThrow({
where: { laufId: lauf.id, vorlagenSchrittId: schritte.kontoAngelegt.id },
});
expect(angelegt.zustand).toBe(SchrittZustand.ERLEDIGT);
// Ein fehlgeschlagener Schritt wird dagegen überschrieben.
const gruppen = await testPrisma.schrittergebnis.findFirstOrThrow({
where: { laufId: lauf.id, vorlagenSchrittId: schritte.gruppen.id },
});
expect(gruppen.zustand).toBe(SchrittZustand.NICHT_ZUTREFFEND);
expect(gruppen.notiz).toBe("Kunde macht das selbst.");
});
it("verlangt eine Notiz und lehnt einen fremden Abschnitt ab", async () => {
const { admin } = await grundbestand();
const { version, abschnitte } = await vorlageMitVersion();
const fremde = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
await expect(
abschnittNichtZutreffend({ laufId: lauf.id, abschnittId: abschnitte.konto.id, notiz: " " }, admin),
).rejects.toThrow(/Begründung/);
await expect(
abschnittNichtZutreffend(
{ laufId: lauf.id, abschnittId: fremde.abschnitte.konto.id, notiz: "Trifft nicht zu." },
admin,
),
).rejects.toThrow(/gehört nicht zu diesem Lauf/);
expect(
await testPrisma.protokoll.count({ where: { laufId: lauf.id, entitaet: "Schrittergebnis" } }),
).toBe(0);
});
it("schreibt beim zweiten Mal mit derselben Notiz nichts nach", async () => {
const { admin } = await grundbestand();
const { version, abschnitte } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, admin);
await abschnittNichtZutreffend(
{ laufId: lauf.id, abschnittId: abschnitte.konto.id, notiz: "Trifft hier nicht zu." },
admin,
);
const zweites = await abschnittNichtZutreffend(
{ laufId: lauf.id, abschnittId: abschnitte.konto.id, notiz: "Trifft hier nicht zu." },
admin,
);
expect(zweites.gesetzt).toBe(0);
expect(await eintraege(lauf.id, "Schrittergebnis")).toHaveLength(2);
});
});
});
+255
View File
@@ -0,0 +1,255 @@
import { readFile } from "node:fs/promises";
import { beforeEach, describe, expect, it } from "vitest";
import { Betriebsmodus, LaufStatus, SchrittZustand } from "@/generated/prisma/enums";
import { laufZumAbarbeiten, laufZustand } from "@/server/queries/laeufe";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Der Zustand fuer die Aktualisierung alle zehn Sekunden (#013).
*
* Zwei Zusagen haengen daran: zwei Browser am selben Lauf sehen die Aenderung
* des anderen — und die Antwort enthaelt **keine Befehle**, weil ein
* `{{generiertes_passwort}}` bei jeder Aufloesung neu entsteht und dem Techniker
* sonst unter den Haenden wegtauschte.
*/
async function aufbau() {
const eigene = await testPrisma.kunde.create({
data: { name: "Muster", kuerzel: "MUS", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({ data: { id: "global", modus: Betriebsmodus.INTERN } });
const benutzer = await testPrisma.benutzer.create({
data: { email: "t@firma.example", name: "Ali Demir", rolle: "TECHNIKER" },
});
const vorlage = await testPrisma.vorlage.create({ data: { name: "PC" } });
const version = await testPrisma.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 1, status: "ENTWURF" },
});
const abschnitt = await testPrisma.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: "Installation", position: 0 },
});
const schritte = await Promise.all([
testPrisma.vorlagenSchritt.create({
data: { abschnittId: abschnitt.id, titel: "Pflicht", pflicht: true, position: 0 },
}),
testPrisma.vorlagenSchritt.create({
data: {
abschnittId: abschnitt.id,
titel: "Konto",
typ: "BEFEHL",
pflicht: true,
position: 1,
konfig: { shell: "powershell", befehl: '-Password "{{generiertes_passwort}}"' },
},
}),
/*
* Ein zweiter Befehl mit demselben Platzhalter – der Fall, an dem der Review
* zu #013 den Blocker gefunden hat: anlegen und uebergeben sind zwei
* Schritte und muessen dasselbe Passwort meinen.
*/
testPrisma.vorlagenSchritt.create({
data: {
abschnittId: abschnitt.id,
titel: "Passwort übergeben",
typ: "BEFEHL",
position: 2,
konfig: { shell: "powershell", befehl: 'Write-Host "{{generiertes_passwort}}"' },
},
}),
]);
await testPrisma.vorlagenVersion.update({
where: { id: version.id },
data: { status: "VEROEFFENTLICHT", veroeffentlichtAm: new Date() },
});
const lauf = await testPrisma.lauf.create({
data: { vorlagenVersionId: version.id, titel: "PC-001", kundeId: eigene.id },
});
await testPrisma.schrittergebnis.createMany({
data: schritte.map((schritt) => ({ laufId: lauf.id, vorlagenSchrittId: schritt.id })),
});
return { eigene, lauf, schritte, benutzer: { id: benutzer.id, rolle: benutzer.rolle } };
}
describe.runIf(datenbankVerfuegbar)("Zustand eines Laufs", () => {
beforeEach(async () => {
await datenbankLeeren();
});
it("zählt die offenen Pflichtschritte", async () => {
const { lauf, schritte, benutzer } = await aufbau();
expect((await laufZustand(lauf.id, benutzer))?.anzahlPflichtOffen).toBe(2);
await testPrisma.schrittergebnis.updateMany({
where: { laufId: lauf.id, vorlagenSchrittId: schritte[0]!.id },
data: { zustand: SchrittZustand.ERLEDIGT },
});
expect((await laufZustand(lauf.id, benutzer))?.anzahlPflichtOffen).toBe(1);
});
it("zählt einen nicht zutreffenden Schritt als vom Tisch", async () => {
// „Nicht zutreffend“ heisst: bewusst uebersprungen, mit Begruendung. Als
// offen zu zaehlen hiesse, den Abschluss zu verhindern, den die Regel
// ausdruecklich erlaubt.
const { lauf, schritte, benutzer } = await aufbau();
await testPrisma.schrittergebnis.updateMany({
where: { laufId: lauf.id, vorlagenSchrittId: schritte[0]!.id },
data: { zustand: SchrittZustand.NICHT_ZUTREFFEND, notiz: "kein AD-Konto nötig" },
});
expect((await laufZustand(lauf.id, benutzer))?.anzahlPflichtOffen).toBe(1);
});
it("zählt einen fehlgeschlagenen Schritt **nicht** als vom Tisch", async () => {
const { lauf, schritte, benutzer } = await aufbau();
await testPrisma.schrittergebnis.updateMany({
where: { laufId: lauf.id, vorlagenSchrittId: schritte[0]!.id },
data: { zustand: SchrittZustand.FEHLGESCHLAGEN, notiz: "Server antwortet nicht" },
});
expect((await laufZustand(lauf.id, benutzer))?.anzahlPflichtOffen).toBe(2);
});
it("liefert, wer wann bearbeitet hat", async () => {
// Zwei Browser am selben Lauf sollen sehen, dass der andere etwas getan hat
// — und wer es war.
const { lauf, schritte, benutzer } = await aufbau();
await testPrisma.schrittergebnis.updateMany({
where: { laufId: lauf.id, vorlagenSchrittId: schritte[0]!.id },
data: { zustand: SchrittZustand.ERLEDIGT, aktualisiertVonId: benutzer.id },
});
const zustand = await laufZustand(lauf.id, benutzer);
const schritt = zustand?.schritte.find((eintrag) => eintrag.schrittId === schritte[0]!.id);
expect(schritt?.bearbeitetVon).toBe("Ali Demir");
expect(schritt?.bearbeitetAm).toBeInstanceOf(Date);
});
it("enthält keinen einzigen Befehl", async () => {
/*
* Die Zusage, an der alles haengt: ein `{{generiertes_passwort}}` entsteht
* bei jeder Aufloesung neu. Kaeme der Befehl alle zehn Sekunden mit, taeusche
* die Anzeige ein Passwort vor, das schon nicht mehr gilt — und der
* Techniker tippt es gerade ab.
*/
const { lauf, benutzer } = await aufbau();
const zustand = await laufZustand(lauf.id, benutzer);
const alsText = JSON.stringify(zustand);
expect(alsText).not.toContain("Password");
expect(alsText).not.toContain("{{");
expect(Object.keys(zustand ?? {}).sort()).toEqual([
"anzahlKommentare",
"anzahlPflichtOffen",
"geaendertAm",
"schritte",
"status",
]);
});
it("meldet auch einen Kommentar und eine Änderung an den Kopfdaten", async () => {
/*
* „Zwei Browser sehen die Arbeit des anderen“ galt vorher nur fuer
* Schrittzustaende: ein Kommentar des Kollegen und ein geaenderter Titel
* blieben unsichtbar, bis jemand von sich aus neu lud (Reviewbefund zu
* #013). Der Client vergleicht die Antwort als Ganzes — was hier fehlt,
* loest keine Aktualisierung aus.
*/
const { lauf, benutzer } = await aufbau();
const vorher = JSON.stringify(await laufZustand(lauf.id, benutzer));
await testPrisma.kommentar.create({
data: { laufId: lauf.id, benutzerId: benutzer.id, rumpf: "Rückfrage ans Team" },
});
const mitKommentar = JSON.stringify(await laufZustand(lauf.id, benutzer));
expect(mitKommentar).not.toBe(vorher);
await testPrisma.lauf.update({ where: { id: lauf.id }, data: { titel: "PC-001 (neuer Titel)" } });
expect(JSON.stringify(await laufZustand(lauf.id, benutzer))).not.toBe(mitKommentar);
});
it("liefert den Status mit", async () => {
const { lauf, benutzer } = await aufbau();
expect((await laufZustand(lauf.id, benutzer))?.status).toBe(LaufStatus.OFFEN);
});
it("gibt einen Lauf eines fremden Kunden nicht heraus", async () => {
const { lauf, benutzer } = await aufbau();
const fremder = await testPrisma.kunde.create({ data: { name: "Fremd", kuerzel: "FRD" } });
await testPrisma.einstellung.update({
where: { id: "global" },
data: { modus: Betriebsmodus.DIENSTLEISTUNG },
});
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
await testPrisma.benutzerKunde.create({ data: { benutzerId: benutzer.id, kundeId: fremder.id } });
expect(await laufZustand(lauf.id, benutzer)).toBeUndefined();
});
it("liefert nichts für einen Lauf, den es nicht gibt", async () => {
const { benutzer } = await aufbau();
expect(await laufZustand("gibtsnicht", benutzer)).toBeUndefined();
});
});
describe.runIf(datenbankVerfuegbar)("Ein Lauf, ein Passwort je Seitenaufbau", () => {
beforeEach(datenbankLeeren);
it("setzt in alle Befehle einer Seite dasselbe Passwort", async () => {
/*
* Der Blocker aus dem Review zu #013. Ein Onboarding legt das Konto in einem
* Schritt an und uebergibt das Passwort im naechsten; standen dort zwei
* verschiedene, kam der Benutzer nicht in sein Konto — und niemand konnte
* sagen, welches das richtige war, weil keines gespeichert wird.
*/
const { lauf, benutzer } = await aufbau();
const daten = await laufZumAbarbeiten(lauf.id, benutzer);
const befehle = (daten?.abschnitte ?? []).flatMap((a) =>
a.schritte.map((schritt) => schritt.befehl?.text).filter((text): text is string => !!text),
);
expect(befehle).toHaveLength(2);
const passwoerter = befehle.map((text) => /"([^"]+)"/u.exec(text)?.[1]);
expect(passwoerter[0]).toHaveLength(20);
expect(passwoerter[0]).toBe(passwoerter[1]);
// Und das Skript des Abschnitts traegt dasselbe, nicht ein drittes.
expect(daten?.abschnitte[0]?.skript).toContain(passwoerter[0]);
});
it("erzeugt beim nächsten Aufbau ein anderes", async () => {
// Die Regel „bei jeder Anzeige neu“ bleibt: gespeichert wird nichts.
const { lauf, benutzer } = await aufbau();
const einmal = (await laufZumAbarbeiten(lauf.id, benutzer))?.abschnitte[0]?.schritte[1]?.befehl?.text;
const nochmal = (await laufZumAbarbeiten(lauf.id, benutzer))?.abschnitte[0]?.schritte[1]?.befehl?.text;
expect(einmal).toBeDefined();
expect(einmal).not.toBe(nochmal);
});
});
/**
* Die zweite Haelfte derselben Zusage (#013).
*
* Dass die Polling-Antwort keine Befehle traegt, nuetzt nichts, wenn das Polling
* stattdessen die ganze Seite neu bauen laesst: beim naechsten Aufbau entsteht
* ein anderes `{{generiertes_passwort}}` als das, das der Techniker gerade
* gelesen hat. Steht eines auf der Seite, wird deshalb gefragt statt geladen.
*/
describe("Die Aktualisierung tauscht kein angezeigtes Passwort aus", () => {
it("fragt, wenn ein Passwort auf der Seite steht, und laedt sonst von selbst", async () => {
const { reaktionAufAenderung } = await import("@/components/laeufe/lauf-aktualisierung");
expect(reaktionAufAenderung(true)).toBe("fragen");
expect(reaktionAufAenderung(false)).toBe("neu-laden");
});
it("reicht die Angabe von der Laufseite durch", async () => {
/*
* Ein Blick in den Quelltext, und zwar bewusst: fuer Client-Komponenten gibt
* es in diesem Projekt keine DOM-Umgebung, und die Entscheidung oben waere
* wertlos, wenn die Seite sie nie mitgibt. Der Test prueft genau die
* Verdrahtung — nicht mehr, aber auch nicht weniger.
*/
const quelle = await readFile(new URL("../src/app/(app)/laeufe/[id]/page.tsx", import.meta.url), "utf8");
expect(quelle).toContain("passwortSichtbar={passwortSichtbar}");
expect(quelle).toMatch(/passwortSichtbar\s*=\s*abschnitte\.some\(/u);
expect(quelle).toContain("mitPasswort === true");
});
});