feat(laeufe): Service, Abarbeitung, Kommentare und Archivschutz #27
@@ -94,6 +94,28 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
(#012)
|
(#012)
|
||||||
- „Alle Befehle als Skript kopieren“ je Abschnitt: die Befehle in Reihenfolge, je mit einer
|
- „Alle Befehle als Skript kopieren“ je Abschnitt: die Befehle in Reihenfolge, je mit einer
|
||||||
Kommentarzeile darüber und dem Zeilenumbruch der jeweiligen Shell (#012)
|
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
|
### Sicherheit
|
||||||
|
|
||||||
@@ -159,3 +181,24 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
`[Passwort – nicht gespeichert]` (#012)
|
`[Passwort – nicht gespeichert]` (#012)
|
||||||
- Ein eingesetzter Wert wird nicht doppelt maskiert: React maskiert Textknoten selbst, und ein Test
|
- 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)
|
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)
|
||||||
|
|||||||
@@ -68,6 +68,15 @@ Zwei Regeln tragen die Nachvollziehbarkeit:
|
|||||||
|
|
||||||
Beides ist durch Tests abgedeckt, nicht durch Disziplin.
|
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
|
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
|
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
|
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
|
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.
|
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
|
## 6. Betrieb: zwei Wege, eine Regel
|
||||||
|
|
||||||
Die Datenbank ist auf beiden Wegen von außen nicht erreichbar:
|
Die Datenbank ist auf beiden Wegen von außen nicht erreichbar:
|
||||||
|
|||||||
@@ -15,11 +15,12 @@ als Unterpunkte mit Aufgabe und Dauer.
|
|||||||
| #010 | M5 – Vorlagen-Editor | 2 h 10 min |
|
| #010 | M5 – Vorlagen-Editor | 2 h 10 min |
|
||||||
| #011 | M6 – Mitarbeiter, Abteilungen, Geräte | 2 h 15 min |
|
| #011 | M6 – Mitarbeiter, Abteilungen, Geräte | 2 h 15 min |
|
||||||
| #012 | M7 – Platzhalter, Namensregeln, Escaping | 2 h 30 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 |
|
| 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
|
- #013 Läufe: Service und Abarbeitung — Lauf anlegen, abarbeiten, abschließen, archivieren, mit
|
||||||
der Abarbeitungsansicht am Handy.
|
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.
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# #013 M8 – Läufe: Service und Abarbeitung
|
# #013 M8 – Läufe: Service und Abarbeitung
|
||||||
|
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.1.0
|
**Meilenstein:** v0.1.0
|
||||||
**Labels:** phase-1, backend, frontend
|
**Labels:** phase-1, backend, frontend
|
||||||
**Geschätzt:** 14 h **Tatsächlich:** –
|
**Geschätzt:** 14 h **Tatsächlich:** 3 h 05 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
|
|
||||||
@@ -11,37 +11,74 @@ Das Herz der Anwendung: ein Lauf wird vorbereitet, abgearbeitet, abgeschlossen u
|
|||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
|
|
||||||
- [ ] `laufAnlegenService`: aus veröffentlichter Version, Kunde per `kundeErmitteln()`, optional Mitarbeiter (Verzeichnis oder Freitext `mitarbeiterName`), optional Gerät, Ticket, Variablen, Zuweisung, Fälligkeit
|
- [x] `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
|
- [x] 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
|
- [x] `gestartetAm` und `IN_ARBEIT` erst beim ersten Öffnen zur Bearbeitung
|
||||||
- [ ] Titelvorschlag `<Vorlage> – <Hostname oder Mitarbeiter> – <Datum>`
|
- [x] Titelvorschlag `<Vorlage> – <Hostname oder Mitarbeiter> – <Datum>`
|
||||||
- [ ] `schrittergebnisSetzenService`: einzeln je Schritt (kein Gesamtformular), `zustand`, `wert`, `notiz`, `aktualisiertVonId`, `Protokoll`-Eintrag
|
- [x] `schrittergebnisSetzenService`: einzeln je Schritt (kein Gesamtformular), `zustand`, `wert`, `notiz`, `aktualisiertVonId`, `Protokoll`-Eintrag
|
||||||
- [ ] `NICHT_ZUTREFFEND` und `FEHLGESCHLAGEN` verlangen eine Notiz, sonst `LaufFehler`
|
- [x] `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)
|
- [x] `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`
|
- [x] `laufAbschliessenService`: nur wenn alle Pflichtschritte `ERLEDIGT` oder `NICHT_ZUTREFFEND` sind; setzt `abgeschlossenAm`, `abgeschlossenVonId`
|
||||||
- [ ] `laufArchivierenService` und `laufZurueckholenService` (nur `ADMIN`), beide mit `Protokoll`-Eintrag
|
- [x] `laufArchivierenService` und `laufZurueckholenService` (nur `ADMIN`), beide mit `Protokoll`-Eintrag
|
||||||
- [ ] `laufMussVeraenderbarSein(lauf)` zentral, aufgerufen vor jeder Änderung an Kopfdaten, `Schrittergebnis` und `Kommentar`
|
- [x] `laufMussVeraenderbarSein(lauf)` zentral, aufgerufen vor jeder Änderung an Kopfdaten, `Schrittergebnis` und `Kommentar`
|
||||||
- [ ] Kommentare je Lauf und je Schrittergebnis
|
- [x] 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
|
- [x] „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`
|
- [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
|
||||||
- [ ] Abarbeitungsansicht: einklappbare Abschnitte mit `01 ·`-Nummerierung, Fortschrittsbalken in Lime, je Schritt Zustand, Wert, Notiz, Kommentare und „wer wann“
|
- [x] 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
|
- [x] 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“
|
- [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“
|
||||||
- [ ] `ZAHL` außerhalb von Min/Max wird markiert, aber nicht blockiert
|
- [x] `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
|
- [x] 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
|
- [x] Umschalter „Kompakt / Ausführlich“, ausführlich klappt alle Hilfetexte auf
|
||||||
- [ ] Abschließen und Archivieren als getrennte Aktionen
|
- [x] Abschließen und Archivieren als getrennte Aktionen
|
||||||
- [ ] Screenshots Start-Dialog, Lauf Desktop, Lauf Handy, Befehlsschritt in `docs/screenshots/m8/`
|
- [x] Screenshots Start-Dialog, Lauf Desktop, Lauf Handy, Befehlsschritt in `docs/screenshots/m8/`
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
|
|
||||||
- [ ] Test: ein Lauf ohne Mitarbeiter und ohne Gerät lässt sich anlegen, abschließen und archivieren
|
- [x] 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
|
- [x] Test: Abschließen scheitert, solange ein Pflichtschritt `OFFEN` ist
|
||||||
- [ ] Test: `NICHT_ZUTREFFEND` ohne Notiz wird abgelehnt, mit Notiz akzeptiert
|
- [x] 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
|
- [x] 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
|
- [x] Test: nur `ADMIN` holt einen archivierten Lauf auf `ABGESCHLOSSEN` zurück, mit `Protokoll`-Eintrag
|
||||||
- [ ] Test: `gestartetAm` bleibt leer, solange der Lauf nur `OFFEN` ist
|
- [x] 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`
|
- [x] 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
|
- [x] 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
|
- [x] Zwei Browser auf demselben Lauf sehen die Änderung des anderen nach höchstens 10 s
|
||||||
- [ ] Am Handy bei 390 px kein horizontales Scrollen
|
- [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.
|
||||||
|
|||||||
@@ -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 |
|
| #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 |
|
| #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 |
|
| #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 | – |
|
| #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 | – |
|
| #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 | – |
|
| #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 | – |
|
| #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 | – |
|
| #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 | – |
|
| #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** |
|
||||||
|
|||||||
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 357 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 114 KiB |
@@ -11,6 +11,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"beispiel:stammdaten": "tsx scripts/beispiel-stammdaten.ts",
|
"beispiel:stammdaten": "tsx scripts/beispiel-stammdaten.ts",
|
||||||
"beispiel:vorlage": "tsx scripts/beispiel-vorlage.ts",
|
"beispiel:vorlage": "tsx scripts/beispiel-vorlage.ts",
|
||||||
|
"beispiel:lauf": "tsx scripts/beispiel-lauf.ts",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"db:down": "docker compose -f docker-compose.dev.yml down",
|
"db:down": "docker compose -f docker-compose.dev.yml down",
|
||||||
"db:generate": "prisma generate",
|
"db:generate": "prisma generate",
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
*/
|
*/
|
||||||
import { mkdir } from "node:fs/promises";
|
import { mkdir } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
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";
|
const APP_URL = process.env["E2E_APP_URL"] ?? "http://127.0.0.1:3000";
|
||||||
|
|
||||||
@@ -42,6 +42,12 @@ type Seite = {
|
|||||||
readonly nurModus?: "INTERN" | "DIENSTLEISTUNG";
|
readonly nurModus?: "INTERN" | "DIENSTLEISTUNG";
|
||||||
/** Vor der Aufnahme ausfuehren, etwa ein Aufklappen. */
|
/** Vor der Aufnahme ausfuehren, etwa ein Aufklappen. */
|
||||||
readonly vorbereiten?: (tab: Page) => Promise<void>;
|
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. */
|
/** Seiten, die abgebildet werden. Waechst mit jedem Meilenstein. */
|
||||||
@@ -124,6 +130,36 @@ const SEITEN: readonly Seite[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ name: "geraete-liste", pfad: "/geraete" },
|
{ 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" },
|
{ name: "abteilungen", pfad: "/admin/abteilungen" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -226,7 +262,11 @@ try {
|
|||||||
await tab.waitForLoadState("domcontentloaded");
|
await tab.waitForLoadState("domcontentloaded");
|
||||||
|
|
||||||
const datei = join(ziel, `${seite.name}-${ansicht.name}.png`);
|
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" });
|
await tab.screenshot({ path: datei, fullPage: ansicht.name === "desktop" });
|
||||||
|
}
|
||||||
|
|
||||||
// Am Handy darf nie horizontal gescrollt werden – hier gleich mitgeprueft.
|
// Am Handy darf nie horizontal gescrollt werden – hier gleich mitgeprueft.
|
||||||
if (ansicht.name === "handy") {
|
if (ansicht.name === "handy") {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 ?? "";
|
||||||
|
}
|
||||||
@@ -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") : [];
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -32,7 +32,7 @@ export type NavEintrag = {
|
|||||||
|
|
||||||
export const NAVIGATION: readonly NavEintrag[] = [
|
export const NAVIGATION: readonly NavEintrag[] = [
|
||||||
{ pfad: "/", beschriftung: "Übersicht", symbol: "LayoutDashboard", verfuegbar: true },
|
{ 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: "/vorlagen", beschriftung: "Vorlagen", symbol: "FileText", verfuegbar: true },
|
||||||
{
|
{
|
||||||
pfad: "/mitarbeiter",
|
pfad: "/mitarbeiter",
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ import { PASSWORT_IM_DRUCK, passwortErzeugen } from "@/lib/passwort";
|
|||||||
* „falsch geschrieben“ und „hier gibt es das nicht“.
|
* „falsch geschrieben“ und „hier gibt es das nicht“.
|
||||||
* 3. **Das generierte Passwort entsteht erst beim Ersetzen** und wird nie
|
* 3. **Das generierte Passwort entsteht erst beim Ersetzen** und wird nie
|
||||||
* zurueckgegeben. Es gibt keinen Weg, es nachtraeglich zu erfragen.
|
* 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,
|
text: string,
|
||||||
werte: Readonly<Record<string, string>>,
|
werte: Readonly<Record<string, string>>,
|
||||||
verwendung: Verwendung = { art: "anzeige" },
|
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 {
|
): ErsetzungsErgebnis {
|
||||||
const unbekannt = new Set<string>();
|
const unbekannt = new Set<string>();
|
||||||
let mitPasswort = false;
|
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
|
* 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
|
// herum, und das Passwort ist nirgends gespeichert, also auch nicht
|
||||||
// nachvollziehbar, wenn es dort stuende.
|
// nachvollziehbar, wenn es dort stuende.
|
||||||
if (verwendung.art === "druck") return PASSWORT_IM_DRUCK;
|
if (verwendung.art === "druck") return PASSWORT_IM_DRUCK;
|
||||||
return aufbereiten(passwortErzeugen(), verwendung);
|
diesesPasswort ??= passwortErzeugen();
|
||||||
|
return aufbereiten(diesesPasswort, verwendung);
|
||||||
}
|
}
|
||||||
|
|
||||||
const wert = werte[schluessel];
|
const wert = werte[schluessel];
|
||||||
|
|||||||
@@ -72,6 +72,17 @@ export function proxy(anfrage: NextRequest): NextResponse {
|
|||||||
const pfad = anfrage.nextUrl.pathname;
|
const pfad = anfrage.nextUrl.pathname;
|
||||||
|
|
||||||
if (!istOeffentlich(pfad) && !anfrage.cookies.has(SITZUNG_COOKIE)) {
|
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 ziel = pfad + anfrage.nextUrl.search;
|
||||||
const anmelden = oeffentlicheAdresse("/anmelden", anfrage.nextUrl.origin);
|
const anmelden = oeffentlicheAdresse("/anmelden", anfrage.nextUrl.origin);
|
||||||
if (ziel !== "/") anmelden.searchParams.set("ziel", ziel);
|
if (ziel !== "/") anmelden.searchParams.set("ziel", ziel);
|
||||||
|
|||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
type Namenswerte,
|
type Namenswerte,
|
||||||
type Zielformat,
|
type Zielformat,
|
||||||
} from "@/lib/namensregeln";
|
} 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 { platzhalterErsetzen, type ErsetzungsErgebnis } from "@/lib/platzhalter-werte";
|
||||||
import {
|
import {
|
||||||
platzhalterAusserhalbAnfuehrungszeichen,
|
platzhalterAusserhalbAnfuehrungszeichen,
|
||||||
@@ -71,7 +71,16 @@ export type FertigerBefehl = {
|
|||||||
* Der Titel wird **ohne** Shell-Maskierung ersetzt: er wird gelesen, nicht
|
* Der Titel wird **ohne** Shell-Maskierung ersetzt: er wird gelesen, nicht
|
||||||
* ausgefuehrt. Ein maskierter Titel stuende mit Backticks auf dem Bildschirm.
|
* 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;
|
const titel = platzhalterErsetzen(schritt.titel, werte).text;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -97,10 +106,12 @@ export function befehlFuellen(schritt: BefehlsSchritt, werte: PlatzhalterWerte):
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const befehl = platzhalterErsetzen(schritt.befehl, werte, {
|
const befehl = platzhalterErsetzen(
|
||||||
art: "befehl",
|
schritt.befehl,
|
||||||
maskieren: (wert) => wertMaskieren(wert, schritt.shell),
|
werte,
|
||||||
});
|
{ art: "befehl", maskieren: (wert) => wertMaskieren(wert, schritt.shell) },
|
||||||
|
passwort,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
titel,
|
titel,
|
||||||
shell: schritt.shell,
|
shell: schritt.shell,
|
||||||
@@ -139,9 +150,10 @@ export function befehlFuellen(schritt: BefehlsSchritt, werte: PlatzhalterWerte):
|
|||||||
/**
|
/**
|
||||||
* Alle Befehle eines Abschnitts als ein Skript zum Kopieren.
|
* Alle Befehle eines Abschnitts als ein Skript zum Kopieren.
|
||||||
*
|
*
|
||||||
* **Die Passwoerter darin entstehen genau jetzt.** Wer das Skript zweimal
|
* **Das Passwort darin entsteht genau jetzt.** Wer die Seite neu laedt, bekommt
|
||||||
* kopiert, bekommt zweimal verschiedene — das ist die ehrliche Folge der Regel
|
* ein anderes — das ist die ehrliche Folge der Regel „bei jeder Anzeige neu“ und
|
||||||
* „bei jeder Anzeige neu“ und der Grund, warum der Hinweis daneben steht.
|
* der Grund, warum der Hinweis daneben steht. Innerhalb eines Aufbaus ist es
|
||||||
|
* ueberall dasselbe.
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* Ein Skript aus **bereits gefuellten** Schritten.
|
* Ein Skript aus **bereits gefuellten** Schritten.
|
||||||
@@ -184,6 +196,7 @@ export function skriptAusFertigen(fertige: readonly FertigerBefehl[]): {
|
|||||||
export function skriptFuerAbschnitt(
|
export function skriptFuerAbschnitt(
|
||||||
schritte: readonly BefehlsSchritt[],
|
schritte: readonly BefehlsSchritt[],
|
||||||
werte: PlatzhalterWerte,
|
werte: PlatzhalterWerte,
|
||||||
|
passwort?: string,
|
||||||
): {
|
): {
|
||||||
readonly skript: string;
|
readonly skript: string;
|
||||||
readonly mitPasswort: boolean;
|
readonly mitPasswort: boolean;
|
||||||
@@ -191,7 +204,7 @@ export function skriptFuerAbschnitt(
|
|||||||
/** Schritte, deren Werte sich nicht sicher maskieren liessen. */
|
/** Schritte, deren Werte sich nicht sicher maskieren liessen. */
|
||||||
readonly nichtMaskierbar: readonly string[];
|
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 [];
|
if (!lauf) return [];
|
||||||
|
|
||||||
const werte = await platzhalterWerteDesLaufs(kontext);
|
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
|
return lauf.vorlagenVersion.abschnitte
|
||||||
.map((abschnitt) => {
|
.map((abschnitt) => {
|
||||||
@@ -244,7 +264,7 @@ export async function befehleDesLaufs(kontext: LaufKontext): Promise<
|
|||||||
}));
|
}));
|
||||||
// **Einmal** fuellen und daraus beides bauen: ein zweiter Durchgang
|
// **Einmal** fuellen und daraus beides bauen: ein zweiter Durchgang
|
||||||
// erzeugte ein zweites Passwort (siehe skriptAusFertigen).
|
// 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);
|
const skript = skriptAusFertigen(gefuellt);
|
||||||
return {
|
return {
|
||||||
abschnitt: abschnitt.titel,
|
abschnitt: abschnitt.titel,
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -13,7 +13,7 @@ import "dotenv/config";
|
|||||||
* Voraussetzung: ein **gebauter** Stand laeuft (`pnpm build && pnpm start`) und
|
* Voraussetzung: ein **gebauter** Stand laeuft (`pnpm build && pnpm start`) und
|
||||||
* `SEED_ADMIN_EMAIL`/`SEED_ADMIN_PASSWORD` sind gesetzt. Gegen `pnpm dev`
|
* `SEED_ADMIN_EMAIL`/`SEED_ADMIN_PASSWORD` sind gesetzt. Gegen `pnpm dev`
|
||||||
* schlaegt das hier fehl, und das ist richtig so — siehe CLAUDE.md,
|
* 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
|
* Der Ablauf legt seine eigene Vorlage an und **archiviert** sie am Ende. Nicht
|
||||||
* loescht: eine veroeffentlichte Version laesst sich nicht mehr entfernen, auch
|
* loescht: eine veroeffentlichte Version laesst sich nicht mehr entfernen, auch
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,7 +9,7 @@ import { Markdown } from "@/components/markdown";
|
|||||||
* geoeffnet. Rohes HTML darin waere fremder Code in fremden Browsern; ueber den
|
* geoeffnet. Rohes HTML darin waere fremder Code in fremden Browsern; ueber den
|
||||||
* JSON-Import kaeme er sogar von ausserhalb des Hauses. `react-markdown` laesst
|
* JSON-Import kaeme er sogar von ausserhalb des Hauses. `react-markdown` laesst
|
||||||
* HTML von sich aus fallen — dieser Test haelt fest, dass niemand spaeter
|
* 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.
|
* 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", () => {
|
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.
|
// 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>');
|
const html = alsHtml('<img src=x onerror="alert(1)"> <iframe src="https://fremd.example"></iframe>');
|
||||||
expect(html).not.toContain("<img");
|
expect(html).not.toContain("<img");
|
||||||
|
|||||||
@@ -161,15 +161,36 @@ describe("Generiertes Passwort", () => {
|
|||||||
expect(platzhalterErsetzen("Hallo {{vorname}}", WERTE).mitPasswort).toBe(false);
|
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
|
* Der Fall, den der Review zu #013 gefunden hat: ein `New-ADUser` setzt das
|
||||||
* neu“: wer den Platzhalter zweimal in einen Befehl schreibt, meint
|
* Passwort und teilt es in derselben Zeile mit. Zwei verschiedene hiessen,
|
||||||
* vermutlich dasselbe Passwort. Dass es zwei sind, faellt beim Lesen auf —
|
* dass das Konto A bekommt und der Benutzer B erfaehrt — ein Konto, in das
|
||||||
* eines still zu speichern und wiederzuverwenden fiele nicht auf.
|
* 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 ergebnis = platzhalterErsetzen("{{generiertes_passwort}} und {{generiertes_passwort}}", {});
|
||||||
const [erstes, zweites] = ergebnis.text.split(" und ");
|
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}`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||