feat(doku): Anleitung, PDF und Ende-zu-Ende-Abnahme #36

Merged
florian.wilde merged 1 commits from feat/18-e2e-anleitung-pdf into main 2026-09-26 09:04:16 +00:00
104 changed files with 4527 additions and 299 deletions
+6
View File
@@ -22,6 +22,12 @@ blob-report/
.env.* .env.*
!.env.example !.env.example
# Die Anleitung als PDF entsteht aus den Markdown-Kapiteln und den Bildern, die
# beide im Repo liegen (`pnpm docs:pdf`). Ein Erzeugnis von 8,7 MB, das sich bei
# jedem Lauf vollstaendig aendert, gehoert nicht in die Versionsgeschichte — dem
# Release liegt es bei, so steht es im Auftrag.
/docs/anleitung/*.pdf
# Werkzeuge # Werkzeuge
*.tsbuildinfo *.tsbuildinfo
.DS_Store .DS_Store
+17
View File
@@ -171,9 +171,22 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
- Namensregeln und die Sperr-OU kommen mit dem Seed: „Namen aus den Regeln vorschlagen" schlägt für - Namensregeln und die Sperr-OU kommen mit dem Seed: „Namen aus den Regeln vorschlagen" schlägt für
Maria Huber genau `m.huber@firma.example` und `mhuber` vor — dieselben Werte, die im Maria Huber genau `m.huber@firma.example` und `mhuber` vor — dieselben Werte, die im
Beispielbestand stehen (#017) Beispielbestand stehen (#017)
- Anleitung für Technik und Verwaltung unter `docs/anleitung/`: neun Kapitel von den ersten Schritten
bis zur Fehlerbehebung, mit Bildern, die `pnpm docs:screenshots` reproduzierbar neu erzeugt (#018)
- `pnpm docs:pdf` macht daraus ein A4-PDF mit Deckblatt, Inhaltsverzeichnis und Seitenzahlen — hell
gedruckt, auch wenn die App dunkel ist (#018)
- Screenshots lassen sich markieren: Rahmen in der Akzentfarbe mit Nummer, für die Stellen, die ein
Kapitel gerade beschreibt (#018)
- `{{ou_gesperrt}}` wird aus Admin → Einstellungen vorbelegt. Das Feld war gespeichert und wurde von - `{{ou_gesperrt}}` wird aus Admin → Einstellungen vorbelegt. Das Feld war gespeichert und wurde von
keinem Platzhalter gelesen; `Move-ADObject -TargetPath "{{ou_gesperrt}}"` blieb leer, obwohl die keinem Platzhalter gelesen; `Move-ADObject -TargetPath "{{ou_gesperrt}}"` blieb leer, obwohl die
Einstellungsseite es versprach (#017) Einstellungsseite es versprach (#017)
- Die Ende-zu-Ende-Abläufe prüfen ihre Voraussetzungen, bevor der erste startet: läuft ein gebauter
Stand, steht der Seed-Bestand, und liest der Server **dieselbe** Datenbank wie der Testprozess.
Vorher fiel eine fehlende Voraussetzung erst mitten im Lauf auf — als Zeitablauf oder als „0
Treffer" (#018)
- Jeder Ablauf legt an, was er braucht, und räumt es weg. Keiner setzt mehr voraus, dass die
Datenbank schon Läufe, Entwürfe oder bestimmte Personen enthält, und keiner sucht seine Vorlage
über die Position in der Liste (#018)
### Sicherheit ### Sicherheit
@@ -301,3 +314,7 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
Passwortmanager legte, sperrte den neuen Mitarbeiter aus (#017) Passwortmanager legte, sperrte den neuen Mitarbeiter aus (#017)
- Der Seed vergibt keinen verbrannten Tag-Code neu: er fragt das Vergabebuch und nicht nur die - Der Seed vergibt keinen verbrannten Tag-Code neu: er fragt das Vergabebuch und nicht nur die
Geräteliste, wie es der Gerätedienst tut (#017) Geräteliste, wie es der Gerätedienst tut (#017)
- Der Verzeichnis-Ablauf legt seine eigene Quelle an, statt die erste vorhandene zu nehmen: er hätte
in einer echten Installation die Attributzuordnung der produktiven Quelle überschrieben (#018)
- Ein Nachlauf setzte die Namensregeln auf leer, statt den vorherigen Wert wiederherzustellen — er
löschte damit, was der Seed eingerichtet hatte (#018)
+25 -4
View File
@@ -177,13 +177,34 @@ einlesen.
## Umgebungsvariablen ## Umgebungsvariablen
Alle Variablen mit Erklärung stehen in [`.env.example`](.env.example). Pflicht sind `APP_URL`, Jede Variable steht mit Erklärung in [`.env.example`](.env.example) — hier die Übersicht. **Pflicht**
`APP_SECRET`, `POSTGRES_PASSWORD` und `DATABASE_URL`; der Compose-Stand bricht ab, statt mit sind `APP_URL`, `APP_SECRET`, `POSTGRES_PASSWORD` und `DATABASE_URL`; fehlt eine davon, bricht der
Standardwerten zu starten. Compose-Stand ab, statt mit Standardwerten zu starten.
| Variable | Bedeutung |
|---|---|
| `APP_URL` | Die **öffentliche** Adresse, unter der die App erreichbar ist. Steht sie hinter einem Reverse Proxy, ist es dessen Adresse — nicht die interne. Aus ihr entstehen die QR-Adressen auf den Aufklebern und die OIDC-Rücksprungadresse. |
| `APP_SECRET` | Schlüssel für Sitzungs-Cookies und die Verschlüsselung des LDAP-Bind-Passworts. Mindestens 32 Zeichen, erzeugen mit `openssl rand -base64 48`. **Ändert man ihn, sind alle Sitzungen ungültig und gespeicherte Bind-Passwörter unlesbar.** |
| `DATABASE_URL` | Verbindung zur PostgreSQL-Datenbank. |
| `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` | Zugangsdaten des Datenbank-Containers im Compose-Stand. |
| `AUTH_LOCAL_ENABLED` | `true` schaltet die lokale Anmeldung mit E-Mail und Passwort frei — Notfallzugang und Entwicklung. Auf `false` verschwindet das Formular **und** der Endpunkt lehnt ab. |
| `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` | Der Identitätsanbieter. Discovery über `<issuer>/.well-known/openid-configuration`. |
| `OIDC_NAME` | Beschriftung des Anmeldeknopfs, etwa „Firmenanmeldung“. |
| `OIDC_SCOPE` | Angefragte Scopes; muss einen enthalten, der `groups` liefert. |
| `OIDC_GROUP_ADMIN`, `OIDC_GROUP_EDITOR`, `OIDC_GROUP_TECHNICIAN`, `OIDC_GROUP_VIEWER` | Welche Gruppe welche Rolle bekommt. Die Rolle wird bei **jeder** Anmeldung neu gesetzt. |
| `OIDC_CUSTOMER_GROUP_PREFIX` | Nur im Modus `DIENSTLEISTUNG`: Gruppen mit diesem Präfix weisen einen Benutzer einem Kunden zu. |
| `BIND_ADDR`, `APP_PORT` | Wohin der Compose-Stand die App bindet. Standard `127.0.0.1:3000` — davor gehört ein Reverse Proxy. |
| `APP_VERSION`, `GIT_SHA` | Erscheinen unten links in der Oberfläche. Im Compose-Stand Build-Argumente. |
| `SEED_ADMIN_EMAIL`, `SEED_ADMIN_PASSWORD` | Der Administrator, den `pnpm db:seed` anlegt. Das Passwort wird nur als argon2-Hash gespeichert. Fehlt eines, überspringt der Seed ihn. |
| `SEED_MODE` | `INTERN` (Standard) oder `DIENSTLEISTUNG` — der Alias `SERVICE` tut dasselbe. Betrifft nur den **ersten** Seed-Lauf; danach steht der Modus in der Datenbank. |
| `SEED_ORG_NAME`, `SEED_ORG_KUERZEL` | Name und Kürzel der eigenen Organisation. Das Kürzel steht auf Etiketten und in der Hostname-Regel. |
| `ZEITPLAN_AKTIV` | `false` schaltet den Verzeichnis-Abgleich nach Zeitplan ab. |
| `TEST_DATABASE_URL` | Datenbank für die Tests. Ohne sie überspringen sich alle Datenbanktests. |
## Dokumentation ## Dokumentation
- Anleitung für Techniker und Admins — entsteht mit #018, inkl. Bildern und PDF - [Anleitung für Technik und Verwaltung](docs/anleitung/) — acht Kapitel mit Bildern. `pnpm docs:pdf`
macht daraus ein PDF; jedem Release liegt es bei
- [Architektur und Entscheidungen](docs/architektur.md) - [Architektur und Entscheidungen](docs/architektur.md)
- [Austauschformat für Vorlagen](docs/vorlagen-format.md) — wie eine Vorlage als JSON aussieht - [Austauschformat für Vorlagen](docs/vorlagen-format.md) — wie eine Vorlage als JSON aussieht
- [Anbindung an authentik](docs/betrieb/authentik.md) — Inbetriebnahme, Proxmox und Reverse Proxy folgen mit #019 - [Anbindung an authentik](docs/betrieb/authentik.md) — Inbetriebnahme, Proxmox und Reverse Proxy folgen mit #019
+88
View File
@@ -0,0 +1,88 @@
# Erste Schritte
## Anmelden
Ruf die Adresse auf, die deine IT dir gegeben hat. Ohne Anmeldung landest du immer auf der
Anmeldeseite.
![Die Anmeldeseite mit Feldern für E-Mail und Passwort](bilder/anmelden-desktop.png)
Es gibt bis zu zwei Wege, und welche du siehst, hängt von der Einrichtung ab:
- **Mit … anmelden** — der Knopf oben trägt den Namen deines Anmeldeanbieters, etwa „Mit authentik
anmelden“. Das ist der normale Weg: du gibst dein Passwort dort ein, nicht hier.
- **E-Mail und Passwort** — ein lokales Konto, gedacht als Notfallzugang, wenn der Anbieter
ausfällt. Felder: **E-Mail**, **Passwort**, dann **Anmelden**.
Steht dort „Es ist kein Anmeldeweg eingerichtet“, ist die Anwendung noch nicht fertig
konfiguriert — das löst ein Administrator.
Warst du auf einer bestimmten Seite und deine Anmeldung ist abgelaufen, kommst du nach dem
Anmelden genau dorthin zurück. Das gilt auch für einen gescannten Aufkleber: du landest hinterher
auf der Geräteseite und nicht auf der Übersicht.
**Abmelden** geht über das Symbol neben deinem Namen, unten in der Seitenleiste; am Handy steht es
im Menü.
## Der Aufbau der Oberfläche
Am Schreibtisch liegt links eine dunkle Seitenleiste, rechts der Inhalt. Ganz oben in der Leiste
sitzt der Knopf **Scannen** — dort fängt der Techniker an. Darunter die Navigation:
| Eintrag | Was dort steht |
|---|---|
| **Übersicht** | was dringend ist, was bei dir liegt, woran zuletzt gearbeitet wurde |
| **Läufe** | alle offenen, laufenden und abgeschlossenen Läufe |
| **Vorlagen** | die Checklisten und ihre Versionen |
| **Mitarbeiter & Geräte** | Personen und Geräte, über zwei Reiter umschaltbar |
| **Kunden** | nur im Modus Dienstleistung |
| **Archiv** | Volltextsuche über abgeschlossene und archivierte Läufe |
| **Admin** | Einstellungen, Benutzer, Abteilungen, Verzeichnis — nur für Administratoren |
Was deine Rolle nicht darf, steht gar nicht in der Leiste. Ein ausgegrauter Eintrag heißt dagegen:
diese Seite gibt es noch nicht.
Unten in der Leiste stehen dein Name, deine Rolle, der Knopf zum Abmelden und die Versionsnummer.
Am Handy wird aus der Leiste eine Kopfzeile: links das Zeichen, rechts der Knopf **Scannen** und
daneben das Menü mit denselben Einträgen.
![Die Übersicht am Handy mit Kopfzeile, Scannen-Knopf und Menü](bilder/uebersicht-handy.png)
## Die Übersicht
Die Übersicht ist der Einstieg am Schreibtisch — das Gegenstück zum Aufkleber am Gerät. Sie ist
nach Dringlichkeit sortiert und zeigt vier Blöcke, jeder mit höchstens fünf Läufen:
- **Überfällig** — Läufe, deren Fälligkeitsdatum verstrichen ist und die noch offen oder in Arbeit
sind. Das ist das Einzige auf der Seite in Warnfarbe, und das ist Absicht: riefe jeder Block,
riefe keiner.
- **Mir zugewiesen** — was auf dich wartet. Zugewiesen wird beim Anlegen eines Laufs oder auf der
Geräteseite.
- **Zuletzt bearbeitet** — woran zuletzt gearbeitet wurde, auch von Kolleginnen und Kollegen.
- **Schnellstart** — die Vorlagen, aus denen hier am häufigsten Läufe entstehen. Ein Klick führt
in den Start-Dialog. Der Block erscheint nur, wenn du Läufe bearbeiten darfst.
Jede Zeile trägt den Status, bei Bedarf die Frist und einen Fortschrittsbalken („7 von 21“). Ein
Klick öffnet den Lauf. Leere Blöcke verschwinden nicht, sondern sagen, warum sie leer sind — eine
Seite, deren Abschnitte kommen und gehen, lässt sich nicht lesen.
## Die vier Rollen
Rechte hängen allein an der Rolle, und geprüft wird jede Anfrage neu gegen die Datenbank. Eine
Rollenänderung wirkt also sofort, ohne neues Anmelden.
| Rolle | Darf |
|---|---|
| **Leser** | alles ansehen: Läufe, Vorlagen, Geräte, Personen, Archiv |
| **Techniker** | dazu Läufe starten, abarbeiten, abschließen und archivieren; Geräte und Personen pflegen; Tags registrieren |
| **Redakteur** | dazu Vorlagen anlegen, bearbeiten und veröffentlichen |
| **Administrator** | alles: Benutzer, Verzeichnis, Abteilungen, Einstellungen, Kunden, Tag-Stapel, Geräte löschen — und als Einziger einen Lauf aus dem Archiv zurückholen |
Zwei Dinge liegen bewusst nur beim Administrator: das **Zurückholen aus dem Archiv**, weil das
Archiv die Zusage ist, dass sich ein abgeschlossener Lauf nicht mehr ändert, und das **endgültige
Löschen eines Geräts**, weil dabei ein Tag-Code für immer verbrennt.
Wichtig zu unterscheiden: **Benutzer** sind die Menschen, die Laufzettel bedienen und sich
anmelden. **Mitarbeiter** (im Modus Dienstleistung: **Personen**) sind die Leute, für die Läufe
geführt werden — die kommen meist aus dem Verzeichnis und melden sich hier nie an.
+269
View File
@@ -0,0 +1,269 @@
# Läufe abarbeiten
Ein Lauf ist eine Checkliste für einen konkreten Fall. Er wird vorbereitet, abgearbeitet,
abgeschlossen und irgendwann archiviert. Dieses Kapitel geht diesen Weg von vorn nach hinten
durch — am Schreibtisch und am Handy.
Ein Lauf hat vier Zustände:
| Zustand | Bedeutung |
|---|---|
| **offen** | vorbereitet und meist jemandem zugewiesen. Niemand arbeitet daran. |
| **in Arbeit** | jemand hat ihn begonnen. |
| **abgeschlossen** | fertig für jetzt. Notizen und Kommentare gehen weiter. |
| **archiviert** | schreibgeschützt, für immer. |
„Abgeschlossen“ und „archiviert“ sind bewusst zwei Dinge. Nach dem Abschluss lässt sich noch eine
Seriennummer berichtigen oder ein Kommentar nachtragen — das passiert oft. Erst das **Archivieren**
macht einen Lauf unveränderlich.
## Die Laufliste
![Die Laufliste mit Filtern und Fortschrittsbalken je Lauf](bilder/laeufe-liste-desktop.png)
Unter **Läufe** stehen alle offenen, laufenden und abgeschlossenen Läufe. Archivierte stehen hier
nicht — die findet das [Archiv](#im-archiv-suchen).
Gefiltert wird über die Zeile darüber: **Suche** (Titel, Ticket, Hostname oder Person), **Status**
und das Häkchen **nur meine**. Danach **Filtern**; **zurücksetzen** räumt alles weg. Der Filter
steht in der Adresse, ein Lesezeichen darauf funktioniert also.
Jede Zeile zeigt Titel, Status, gegebenenfalls „seit … fällig“, die Vorlage mit ihrer Version, das
Gerät, die Person und den Fortschritt. Es werden höchstens 200 Läufe gezeigt; fehlt deiner, such
nach Titel, Ticket, Hostname oder Person.
## Einen Lauf anlegen
Es gibt drei Wege, und der schnellste ist meist nicht dieser:
- **Am Gerät**: Aufkleber scannen, auf der Geräteseite eine Schnellstart-Kachel antippen. Der Lauf
entsteht und beginnt sofort, mit Gerät und Besitzer, ohne eine einzige Eingabe. Siehe
[Kapitel 03](03-qr-tags.md).
- **Aus der Übersicht**: der Block **Schnellstart** führt mit vorgewählter Vorlage in den Dialog.
- **Über den Knopf Lauf starten** in der Laufliste — der vollständige Weg, hier beschrieben.
![Der Start-Dialog mit Vorlage, Gerät, Person und den Angaben zum Lauf](bilder/lauf-starten-desktop.png)
Der Dialog fragt in der Reihenfolge, in der man denkt:
1. **Vorlage** — es stehen nur veröffentlichte Vorlagen zur Wahl, jeweils die zuletzt
veröffentlichte Fassung. Steht dort nichts, gibt es noch keine veröffentlichte Vorlage; ein
Entwurf ist noch niemandes Arbeitsanweisung.
2. **Gerät (optional)** — oder „kein Gerät“. Wählst du ein Gerät, wird sein Besitzer als Person
vorgeschlagen.
3. **Person (optional)** — jemand aus dem Verzeichnis, oder „keine Person aus dem Verzeichnis“.
4. **… oder Name als Freitext** — für ein Onboarding, bevor die Person im Verzeichnis steht. Eines
von beidem, nicht beides. Der Freitextname lässt sich später mit dem AD-Konto verknüpfen.
5. **Angaben zu diesem Lauf** — die Variablen der Vorlage. Ein Sternchen heißt Pflicht. Fragt die
Vorlage nach `upn`, `sam`, `mail` oder `hostname`, gibt es den Knopf **Namen aus den Regeln
vorschlagen**: er füllt leere Felder nach den Namensregeln des Hauses, ausgefüllte bleiben
stehen.
6. Aufgeklappt hinter **Titel, Ticket, Abteilung, Zuweisung und Fälligkeit** stehen die Beigaben.
Bleibt der **Titel** leer, bildet Laufzettel ihn aus Vorlage, Gerät oder Person und Datum. Die
**Abteilung** ist wichtiger, als sie aussieht: sie speist `{{ou}}` und `{{gruppen}}` in den
Befehlen dieses Laufs.
7. Im Modus Dienstleistung außerdem der **Kunde**.
**Lauf anlegen** legt ihn an und springt hinein. Er beginnt als **offen** — das ist die Zuweisung.
Gestartet wird er erst, wenn ihn jemand zur Bearbeitung öffnet.
Ein Lauf hängt für immer an der Version, mit der er gestartet wurde. Eine spätere Veröffentlichung
ändert ihn nicht, und den Wechsel auf eine neuere Version weist Laufzettel ausdrücklich ab. Für
eine neuere Fassung startest du einen neuen Lauf.
## Die Abarbeitungsansicht
![Ein Lauf in Arbeit mit Abschnitten, Schritten und Fortschrittsbalken](bilder/lauf-abarbeiten-desktop.png)
Oben stehen Titel, Vorlage mit Version, Gerät, Person und Ticket, darunter der Fortschrittsbalken
und die Knöpfe. Welcher Knopf da ist, hängt vom Zustand ab:
| Zustand | Knopf |
|---|---|
| offen | **Lauf beginnen** |
| in Arbeit | **Lauf abschließen** |
| abgeschlossen | **archivieren** |
| archiviert | **aus dem Archiv zurückholen** (nur Administrator) |
Daneben immer: der Umschalter **Kompakt** / **Ausführlich** und **Drucken**.
**Lauf beginnen** setzt den Lauf auf „in Arbeit“ und hält den Startzeitpunkt fest. Ein Lauf, den
jemand nur aufschlägt, hat noch nicht begonnen — sonst sähe die Liste nach Arbeit aus, die niemand
tut.
**Ausführlich** zeigt die Hilfetexte der Schritte, **Kompakt** blendet sie alle aus. Beim ersten
Mal sind sie der eigentliche Wert, beim dritten Mal stehen sie im Weg.
### Abschnitte
Die Schritte stecken in Abschnitten mit zweistelliger Nummer: `01 · Vorbereitung`. Die Nummer kommt
aus der Position und steht nirgends gespeichert. Ein Klick auf die Überschrift klappt den Abschnitt
zu oder auf; daneben steht „3 von 7“. Abschnitte, in denen nichts mehr zu tun ist, sind beim
Aufbau der Seite schon zu.
Enthält ein Abschnitt Befehlsschritte, gibt es **Alle Befehle als Skript kopieren** — einmal
kopieren statt achtmal.
### Einen Schritt erledigen
Unter jedem Schritt stehen drei Pillen:
- **erledigt** — getan.
- **nicht zutreffend** — trifft in diesem Fall nicht zu. Verlangt eine Begründung.
- **fehlgeschlagen** — hat nicht funktioniert. Verlangt eine Begründung.
„erledigt“ speichert mit einem Klick. Bei den anderen zwei erscheint erst das Feld **Begründung**,
dann der Knopf **Mit Begründung speichern**. Ohne Begründung steht im Archiv ein übersprungener
Schritt ohne Grund — und genau das macht ein Archiv wertlos.
Ein Klick auf die bereits gesetzte Pille nimmt die Angabe zurück; das Zurücknehmen braucht keine
Begründung.
**Jeder Schritt speichert einzeln.** Es gibt kein Gesamtformular und keinen Speichern-Knopf am
Ende: ein verlorener Klick soll nicht zwei Stunden Haken kosten.
Ein rotes Sternchen am Titel heißt **Pflichtschritt**. Ein Pflichtschritt muss erledigt oder
ausdrücklich als nicht zutreffend eingetragen sein, bevor sich der Lauf abschließen lässt.
### Werte erfassen
Je nach Schritt-Typ steht über den Pillen ein Eingabefeld — Text, Zahl, Auswahl oder Datum. Getippt
wird, dann **Wert speichern**. Das ist ein eigener Knopf und kein automatisches Speichern: ein halb
getippter Wert soll nicht in die Datenbank.
Eine Zahl außerhalb der erwarteten Grenzen wird **markiert, nicht abgewiesen** („Erwartet werden 8
bis 64 GB“). Ein Rechner mit 4 GB statt der erwarteten 8 ist ein Befund, den der Lauf festhalten
soll, kein Eingabefehler.
### Befehle kopieren
![Ein Befehlsschritt mit fertigem PowerShell-Befehl und dem Knopf zum Kopieren](bilder/lauf-befehlsschritt-desktop.png)
Ein Schritt vom Typ **Befehl** zeigt den fertigen Befehl in Monospace, mit allen Platzhaltern
eingesetzt. **Befehl kopieren** legt ihn in die Zwischenablage, der Knopf bestätigt kurz mit
„Kopiert.“. Der Text steht vollständig da, du kannst ihn also auch markieren.
Drei Hinweise können daneben stehen:
- **Ein Platzhalter ließ sich nicht einsetzen** — er steht als `{{name}}` im Befehl und muss vor
dem Ausführen von Hand ersetzt werden. Das ist eine Warnung und keine Sperre; vielleicht setzt du
den Wert selbst ein.
- **Ein Passwort** — enthält der Befehl `{{generiertes_passwort}}`, entsteht das Passwort bei jeder
Anzeige neu und wird **nirgends gespeichert**: nicht im Lauf, nicht im Kommentar, nicht im
Protokoll, nicht im Ausdruck. Wer es nicht sofort weiterreicht, hat es verloren. Sobald sich die
Seite neu aufbaut — auch nach dem Speichern eines Schritts — steht dort ein anderes.
- **Der Befehl ließ sich nicht sicher füllen** — dann gibt es keinen Kopier-Knopf, sondern eine
Fehlermeldung. Ein halb maskierter Befehl zum Kopieren wäre die gefährlichste aller Antworten.
### Einen ganzen Abschnitt überspringen
Am Ende jedes Abschnitts sitzt **Abschnitt trifft nicht zu**. Der Fall aus dem Alltag: im
Onboarding gibt es zwei Konto-Abschnitte, und je nach Kunde trifft einer gar nicht zu. Du gibst
eine Begründung ein und drückst **Abschnitt vermerken** — die Begründung steht danach an jedem
Schritt des Abschnitts. Achtzehn Haken einzeln mit derselben Begründung zu versehen macht niemand,
und wer es nicht macht, lässt sie weg.
### Kommentare
Zwei Orte, zwei Bedeutungen:
- **Kommentar zu diesem Schritt** — aufklappbar unter dem Schritt, für das, was genau dort
aufgefallen ist.
- **Kommentare zum Lauf** — der Kasten unten, für alles andere.
Beide fragen dasselbe: „Was sollte man später wissen?“ Dann **Kommentar hinzufügen**. Kommentare
werden mit Name und Zeitpunkt festgehalten und lassen sich nicht löschen.
### Zwei Techniker am selben Lauf
Die Seite fragt alle zehn Sekunden nach Änderungen und lädt sich neu, wenn sich wirklich etwas
geändert hat. Bei verstecktem Tab pausiert die Abfrage.
Steht ein erzeugtes Passwort auf dem Bildschirm, lädt sie **nicht** von selbst neu — stattdessen
erscheint oben ein Streifen: „Jemand anderes hat an diesem Lauf etwas geändert. Beim Neuladen
entsteht ein neues Passwort.“ Du entscheidest mit **Neu laden**, wann es passt.
### Einen Freitextnamen später verknüpfen
Läuft ein Onboarding auf einen Freitextnamen, erscheint oben **„…“ mit einem AD-Konto verknüpfen**.
Zwei Wege: die Person aus der Liste wählen, oder — wenn das Konto gerade erst im Verzeichnis
entstanden und noch nicht abgeglichen ist — ihren **UPN** eintragen. Danach **Verknüpfen**. Ohne
das findet die Person in ihrer Historie später nichts wieder.
## Abschließen
**Lauf abschließen** geht nur, wenn kein Pflichtschritt mehr offen ist. Erledigt und „nicht
zutreffend“ zählen beide, **fehlgeschlagen nicht** — ein fehlgeschlagener Schritt ist entweder zu
beheben oder mit Begründung als nicht zutreffend einzutragen. Solange etwas offen ist, ist der
Knopf gesperrt und darunter steht, was fehlt.
Wird danach eine Änderung nachgetragen, die einen Pflichtschritt wieder offen lässt, nimmt sich der
Abschluss selbst zurück. Damit lügt „abgeschlossen“ nie.
## Archivieren — das ist endgültig
**archivieren** fragt nach: „Nach dem Archivieren ändert sich an diesem Lauf nichts mehr — kein
Kommentar, keine Notiz, kein Schritt.“ Erst **Ja, archivieren** vollzieht es.
Danach ist der Lauf schreibgeschützt. Er zeigt für immer, was damals abgearbeitet wurde. Nur ein
**Administrator** holt ihn mit **aus dem Archiv zurückholen** auf „abgeschlossen“ zurück, und das
wird protokolliert.
Archiviere also erst, wenn wirklich alles drinsteht. Es gibt keinen Grund zur Eile: ein
abgeschlossener Lauf steht auch im Archiv und ist durchsuchbar.
## Drucken
**Drucken** öffnet einen hellen Beleg auf A4, ohne Navigation, zum Abheften. Im Browser dann
**Jetzt drucken**; wer ein PDF braucht, druckt in eine Datei.
![Die Druckansicht eines Laufs auf A4, hell und ohne Navigation](bilder/druckansicht-desktop.png)
Auf dem Blatt stehen Kopfdaten, alle Abschnitte mit Zustand, Wert und Notiz je Schritt und die
Kommentare. **Ein erzeugtes Passwort steht dort nie** — an seiner Stelle steht
`[Passwort – nicht gespeichert]`, und erzeugt wird auch keines. Auf einem Blatt, das jemand
abheftet, darf keines stehen.
Von der Person stehen nur Name, UPN und Abteilung auf dem Beleg. Ein Ausdruck liegt herum, wird
kopiert und weitergegeben; was darauf steht, ist aus der Hand gegeben.
## Im Archiv suchen
![Das Archiv mit Suchfeld, Filtern und Fundstellen im Treffer](bilder/archiv-desktop.png)
Das **Archiv** durchsucht abgeschlossene und archivierte Läufe — ihren Titel, die Notizen und Werte
der Schritte und die Kommentare. Es zeigt zu jedem Treffer die Fundstelle mit dem Text drumherum:
Wer im Archiv sucht, will nicht wissen, *dass* etwas passt, sondern *wo*.
Der **Suchbegriff** braucht mindestens zwei Zeichen; ohne Begriff wird nur gefiltert. Hinter
**Weitere Filter** stehen Person, Abteilung, Gerät, Bearbeiter, Kunde und der Zeitraum
(**abgelegt ab** / **abgelegt bis**). Als Ablage gilt der Zeitpunkt der Archivierung, sonst der des
Abschlusses, sonst der des Anlegens — danach ist auch sortiert. Mit dem Häkchen **auch offene und
laufende Läufe** kommen die noch nicht fertigen dazu.
Gezeigt werden höchstens 100 Treffer; steht das dabei, holt ein engerer Zeitraum oder ein genauerer
Begriff die übrigen hervor.
## Am Handy
Die Abarbeitungsansicht ist für das Handy gemacht, weil dort gearbeitet wird: neben dem
aufgeschraubten Rechner, im Serverraum, vor dem Drucker.
![Ein Lauf am Handy, ein Abschnitt aufgeklappt](bilder/lauf-abarbeiten-handy.png)
Was du wissen solltest:
- **Es gibt nie horizontales Scrollen.** Alles steht untereinander, auch bei 390 Pixeln Breite.
Knöpfe und Pillen sind 44 Pixel hoch und mit dem Daumen zu treffen.
- **Schalte auf Kompakt.** Ohne die Hilfetexte passen dreimal so viele Schritte auf den Schirm.
- **Klapp erledigte Abschnitte zu.** Fertige Abschnitte sind beim Öffnen des Laufs schon zu; beim
Nachtragen einer Notiz bleibt ein selbst aufgeklappter Abschnitt offen, auch wenn dazwischen
gespeichert wird.
- **Jeder Klick speichert sofort.** Bei wackligem WLAN ist das der Unterschied zwischen „ein Haken
fehlt“ und „zwei Stunden fehlen“. Bleibt eine Pille nach dem Tippen ungesetzt, war die
Verbindung weg — tippe sie noch einmal.
- **Befehle kopieren** funktioniert auch am Handy, ist dort aber selten nützlich: den
PowerShell-Befehl führst du ohnehin an einem Rechner aus. Nimm dafür den Weg über die
Übersicht am Schreibtisch oder schicke dir den Lauf per Link.
- **Der Einstieg ist der Aufkleber.** Scannen, Geräteseite, „Starten“ — dazu
[Kapitel 03](03-qr-tags.md).
+164
View File
@@ -0,0 +1,164 @@
# QR-Aufkleber und Tags
Auf jedem Objekt klebt ein Aufkleber mit einem QR-Code und dem Code darunter zum Ablesen, etwa
`LZ-7K3F9Q`. Objekt heißt: nicht nur PCs. Ein Serverschrank, ein Raum, eine Firewall oder ein
Drucker bekommen genauso einen.
Im QR-Code steht nichts als die Adresse dieser Anwendung plus der Tag-Code. Kein Name, keine
Seriennummer, kein Besitzer — ein Aufkleber hängt sichtbar am Gerät und wird von jedem Telefon
gelesen, auch von einem fremden. Hinter der Adresse verlangt Laufzettel eine Anmeldung.
**Ein Tag-Code wird nie geändert und nie neu vergeben.** Auch nicht, nachdem das Gerät archiviert
oder gelöscht wurde. Das ist der Grund, warum der Aufkleber die einzige Angabe am Gerät ist, die
Jahre hält: Hostnamen ändern sich, Besitzer wechseln, Seriennummern werden abgeschrieben.
Im Code gibt es die Zeichen `0`, `O`, `1` und `I` nicht — sie sind zu leicht zu verwechseln.
Groß- und Kleinschreibung ist beim Eintippen unerheblich.
## Zwei Wege, wie ein Tag entsteht
**Weg A — Gerät zuerst.** Du legst das Gerät in der Geräteliste an; der Tag-Code entsteht dabei und
steht danach in der Erfolgsmeldung. Dann druckst du sein Etikett und klebst es auf. Siehe
[Kapitel 05](05-mitarbeiter-geraete.md).
**Weg B — Aufkleber zuerst.** Du legst auf Vorrat einen Stapel Tags an, druckst den Bogen und
klebst die Aufkleber auf, was gerade da ist. Wer später davorsteht, scannt und trägt ein, was es
ist. Das ist der Weg für eine Lieferung von zwanzig Notebooks.
## Einen Stapel Tags anlegen
Das verlangt die Rolle **Administrator**: ein Stapel verbraucht unwiderruflich Tag-Codes. Der Weg
dorthin steht in der Geräteliste unten („Aufkleber auf Vorrat drucken? **Tag-Stapel anlegen**“).
![Die Seite Tags mit dem Formular für einen Stapel und der Liste der noch nicht zugeordneten Tags](bilder/tags-desktop.png)
Unter **Stapel anlegen** steht die **Anzahl**, voreingestellt 50 — das sind gut zwei A4-Bogen und
der übliche Vorrat. Höchstens 500 auf einmal. Im Modus Dienstleistung wählst du außerdem den
**Kunden**. Dann **Stapel anlegen**.
Danach erscheint direkt daneben **Etiketten für diesen Stapel drucken** — genau diese Codes, nicht
alles, was noch offen ist.
> **Jeder Tag verbraucht einen Tag-Code für immer**, auch einer, dessen Aufkleber nie geklebt wird.
> Ein Stapel, den niemand klebt, ist verbraucht. Lege also so viele an, wie du wirklich aufklebst.
Darunter listet **Noch nicht zugeordnet** alle Tags, die auf ein Gerät warten. Jede Zeile führt über
den Code zum Registrierformular und hat rechts **Einzeletikett** zum Nachdrucken. Über der Liste
steht **Etiketten drucken** für den ganzen Vorrat.
Ein Tag ohne Gerät steht auch in der Geräteliste, dort mit dem Kennzeichen „Tag ohne Zuordnung“.
## Etiketten drucken
Es gibt zwei Formate, und die Druckansicht schaltet zwischen ihnen um:
| Format | Maße | Wofür |
|---|---|---|
| **Einzeletikett** | 62 × 29 mm, ein Stück je Seite | Etikettendrucker |
| **A4-Bogen** | 3 × 8 Stück à 70 × 37 mm | Stanzbogen im Laserdrucker |
![Die Druckansicht im Format 62 × 29 mm, ein Etikett je Seite](bilder/etikett-62x29-desktop.png)
![Die Druckansicht als A4-Bogen mit dem Raster aus 3 × 8 Etiketten](bilder/etiketten-a4-desktop.png)
Auf dem Etikett stehen der QR-Code, der Tag-Code zum Ablesen, Hostname oder Bezeichnung des Geräts,
der Besitzer, das Wort „laufzettel“ und im Modus Dienstleistung das Kundenkürzel. Etiketten sind
immer hell, auch wenn die Anwendung dunkel ist.
Vor dem Drucken:
- **Skalierung im Druckdialog auf 100 % stellen und Kopf- und Fußzeilen abschalten** — sonst sitzen
die Etiketten neben ihrer Stanzung.
- Steht oben eine rote Warnung **„APP_URL ist nicht gesetzt“**, brich ab und hol einen
Administrator. Die QR-Codes zeigen dann ins Leere, und auf dem Bildschirm sieht das Etikett
trotzdem richtig aus. Ein geklebter Aufkleber lässt sich nicht berichtigen.
Ein Etikett nachdrucken geht jederzeit: auf der Geräteseite über **Etikett drucken**, in der
Tag-Liste über **Einzeletikett**. Auch das Etikett eines archivierten Geräts lässt sich weiter
drucken — der Aufkleber am Karton im Lager soll lesbar bleiben. Zum Nachdrucken genügt die Rolle
**Leser**; niemand soll für einen abgerissenen Aufkleber den Administrator fragen müssen.
## Einen Aufkleber scannen
Der Knopf **Scannen** steht an beiden Stellen ganz oben — in der Seitenleiste und am Handy in der
Kopfzeile. Halte den Aufkleber ins Bild; Laufzettel springt von selbst weiter, sobald der Code
erkannt ist.
![Der Scanner am Handy mit Kamerabild und dem Feld zum Eintippen](bilder/scannen-handy.png)
Drei Dinge, die passieren können:
- **Der Code gehört zu einem Gerät** — du landest auf der Geräteseite.
- **Der Code gehört zu einem vorgedruckten Tag** — du landest im Registrierformular.
- **Es ist kein Laufzettel-Code** — es erscheint der Hinweis „Das ist kein Laufzettel-Tag. Halte den
Aufkleber mit dem Code LZ-… ins Bild.“
Unter dem Kamerabild steht immer das Feld **Code eintippen**. Es ist der Weg, wenn die Kamera nicht
will: kein HTTPS, verweigerte Berechtigung, Rechner ohne Kamera, ausgefallener QR-Leser. Dann tippst
du den Code ab, der lesbar unter dem QR steht, und drückst **Öffnen**. Ein Scanner, der nicht
startet, darf keine Sackgasse sein.
Am Schreibtisch brauchst du keine Kamera: in der Tag-Liste und in der Geräteliste führt der Code
als Link dorthin, wohin auch der Scan führt.
## Einen unregistrierten Tag registrieren
Steht hinter dem Code noch kein Gerät, erscheint **Gerät registrieren**. Dafür braucht es die Rolle
**Techniker**, **Redakteur** oder **Administrator**; einem Leser sagt die Seite, dass ihm die
Berechtigung fehlt.
![Das Registrierformular hinter einem vorgedruckten Aufkleber](bilder/tag-registrieren-handy.png)
Fünf Angaben, mehr nicht — der Rest lässt sich später in der Geräteliste nachtragen:
- **Typ** — PC, Notebook, Server, Firewall, Switch, Drucker, Raum oder Sonstiges.
- **Hostname** und **Bezeichnung** — eines von beiden genügt. Ein Raum oder ein Serverschrank hat
keinen Hostname, ein Notebook keine Bezeichnung.
- **Seriennummer** und **Inventarnummer**.
- **Besitzer (optional)** — oder „niemand — Server, Raum oder Poolgerät“. Zur Wahl stehen nur
Personen desselben Kunden.
Der **Tag-Code steht oben, aber nicht als Feld**: er ist schon gedruckt und geklebt, und jede
Änderung daran wird abgewiesen.
**Gerät registrieren** führt direkt auf die Geräteseite.
## Die Geräteseite
Das ist das Ziel jedes gescannten Aufklebers — die Seite, die ein Techniker am Handy vor dem Gerät
aufschlägt. Sie beantwortet in dieser Reihenfolge: Was ist das hier? Was wartet daran? Was kann ich
sofort anfangen? Was war schon?
![Die Geräteseite mit Kopfdaten, Zuweisungen, Schnellstart und Historie](bilder/geraeteseite-desktop.png)
- **Kopf** — Name, Tag-Code, Typ, Besitzer, Modell, Serien- und Inventarnummer, Notizen. Rechts
**Etikett drucken**. Hinter **QR-Code anzeigen** steht derselbe Code als Bild auf dem Schirm, für
den Fall, dass der Aufkleber abgerissen ist.
- **Zugewiesen** — was an diesem Gerät wartet. Ein offener Lauf hat den Knopf **Starten**: er setzt
ihn auf „in Arbeit“ und führt hinein. Ein laufender hat **Weiter**, ein fertiger **Ansehen**.
- **Schnellstart** — eine Kachel je Vorlage, die für diesen Gerätetyp hinterlegt ist. Ein Tipp legt
den Lauf mit diesem Gerät und seinem Besitzer an und beginnt ihn, ohne eine einzige Eingabe.
Steht auf der Kachel „fragt noch nach Angaben“, führt sie stattdessen in den Start-Dialog — dort
sind Gerät und Person schon vorbelegt.
- **Vorlage zuweisen** — für später oder für jemand anderen: Vorlage, **Techniker (optional)**,
**Fällig am (optional)**, dann **Lauf zuweisen**. Der Lauf entsteht als **offen** und erscheint
oben unter „Zugewiesen“.
- **Historie** — die letzten 20 Läufe an diesem Gerät, mit Abschlussdatum und Fortschritt. Für die
älteren und die archivierten nimm das [Archiv](02-laeufe-abarbeiten.md).
Ein archiviertes Gerät und ein Tag ohne Zuordnung nehmen keine neue Arbeit auf. Statt Formularen,
die hinterher abgelehnt würden, steht dort dann der nächste Schritt.
![Die Geräteseite am Handy](bilder/geraeteseite-handy.png)
## Am Handy
- **Scannen ist der Einstieg**, und der Knopf steht deshalb in der Kopfzeile immer da.
- **Die Kamera braucht HTTPS.** Auf einer Adresse ohne Verschlüsselung gibt kein Browser sie frei —
dann bleibt das Feld zum Eintippen.
- **Freigabe verweigert?** Gib die Kamera in den Einstellungen des Browsers für diese Seite frei
und lade neu.
- Die rückwärtige Kamera wird von selbst genommen. Halte den Aufkleber in den hellen Rahmen in der
Bildmitte; bei schlechtem Licht hilft ein Schritt zur Seite mehr als näher herangehen.
- **Registrieren geht am Handy vollständig.** Du stehst vor dem Gerät und liest den Hostname vom
Anmeldebildschirm ab — genau dafür sind es nur fünf Felder.
+240
View File
@@ -0,0 +1,240 @@
# Vorlagen bauen
Eine Vorlage ist die Checkliste selbst: was bei einer PC-Neuinstallation zu tun ist, in welcher
Reihenfolge, mit welchen Hilfetexten und welchen Befehlen. Vorlagen anlegen, bearbeiten und
veröffentlichen darf die Rolle **Redakteur** oder **Administrator**.
Die wichtigste Regel steht am Anfang, weil alles andere daraus folgt:
> **Eine veröffentlichte Version wird nie verändert.** Sonst zeigte ein alter Lauf Schritte, die es
> damals nicht gab. „Bearbeiten“ legt deshalb eine vollständige Kopie als **Entwurf** an. Je Vorlage
> gibt es höchstens einen Entwurf. Läufe starten nur an veröffentlichten Versionen und hängen für
> immer an der Version, mit der sie gestartet wurden.
## Die Vorlagenliste
![Die Vorlagenliste mit Filtern und Kennzeichen je Vorlage](bilder/vorlagen-liste-desktop.png)
Gefiltert wird nach **Suche**, **Kategorie**, **Schlagwort** und mit dem Häkchen **auch
archivierte**, dann **Filtern**.
Die Kennzeichen an einer Zeile sagen den Zustand:
| Kennzeichen | Bedeutung |
|---|---|
| **v3** | Version 3 ist veröffentlicht und gültig |
| **noch nicht veröffentlicht** | es gibt nur einen Entwurf; daran lässt sich kein Lauf starten |
| **Entwurf vorhanden** | es wird gerade an einer neuen Version gearbeitet |
| **archiviert** | aus dem Angebot genommen |
| **nur …** | gilt nur für einen Kunden (Modus Dienstleistung) |
Gibt es einen Entwurf, führt **Entwurf öffnen** direkt in den Editor — der häufigste Fall ist,
weiterzuarbeiten an dem, was man gestern angefangen hat.
Unten auf der Seite steht **Vorlage anlegen**: **Name**, **Beschreibung (optional)**, **Kategorie
(optional)**, **Schlagworte, mit Komma getrennt** und im Modus Dienstleistung **Nur für einen
Kunden (optional)**. Die neue Vorlage beginnt als Entwurf. Namen sind nicht eindeutig; zwei
Vorlagen dürfen gleich heißen.
## Die Vorlagenseite
![Die Seite einer Vorlage mit Versionsgeschichte, Kopfdaten und weiteren Aktionen](bilder/vorlagen-seite-desktop.png)
Sie beantwortet drei Fragen auf einen Blick: Was ist gerade gültig? Wird gerade daran gearbeitet?
Was wurde wann veröffentlicht?
**Bearbeiten** ist der wichtigste Knopf. Er erzeugt eine tiefe Kopie der letzten veröffentlichten
Version als neuen Entwurf und führt in den Editor. Gibt es schon einen Entwurf, heißt der Knopf
**Entwurf weiter bearbeiten** und führt genau dorthin — auch wenn zwei Redakteure gleichzeitig
klicken.
Unter **Versionen** steht die Geschichte: Nummer, Veröffentlichungszeitpunkt und -person, die Zahl
der Läufe und die Änderungsnotiz. Je Zeile **Ansehen** (beziehungsweise **Im Editor öffnen** beim
Entwurf) und **Als JSON exportieren**.
Unter **Kopfdaten** stehen Name, Beschreibung, Kategorie, Schlagworte und die Kundenzuordnung. Sie
hängen an der Vorlage, nicht an einer Version — ein Tippfehler im Namen kostet also keine neue
Version. **Kopfdaten speichern** übernimmt sie.
Unter **Weiteres**:
- **Duplizieren** — legt eine neue Vorlage an, mit dem Inhalt der neuesten Version als Entwurf.
Gut als Ausgangspunkt für eine Abwandlung.
- **archivieren** / **wieder freigeben** — archiviert wird, was nicht mehr angeboten werden soll.
Gelöscht wird nichts: an einer Vorlage hängen Läufe. Ein Entwurf einer archivierten Vorlage lässt
sich noch bearbeiten, aber nicht veröffentlichen.
- **Entwurf verwerfen** — mit Rückfrage. Der Entwurf ist danach weg und nicht wiederherstellbar;
die veröffentlichte Version bleibt unberührt.
## Der Editor
![Der Vorlagen-Editor mit Variablen, Abschnitten und Schritten](bilder/vorlagen-editor-desktop.png)
Oben stehen Name, **Version N** und der Zustand — **Entwurf** oder **veröffentlicht** — und daneben
**Nicht gespeichert** oder **gespeichert**.
Unten klebt eine Leiste mit **Speichern** und derselben Auskunft in Worten. **Es gibt kein
automatisches Speichern**: eine halb umgebaute Vorlage soll nicht nebenbei zur Wahrheit werden.
Solange etwas nicht gespeichert ist, fragt der Browser vor dem Verlassen der Seite nach.
Eine veröffentlichte Version öffnet der Editor nur lesend — mit denselben Feldern, nur
schreibgeschützt. Oben steht dann, warum.
### Abschnitte
Ein Abschnitt fasst die Schritte zusammen, die zusammengehören. **Abschnitt hinzufügen** legt einen
an; er bekommt einen **Titel des Abschnitts** und darunter seine Schritte.
Die zweistellige Nummer (`01 · Vorbereitung`) kommt aus der Position und wird **nie gespeichert**.
Schreib sie also nicht in den Titel — beim ersten Umsortieren wäre sie falsch.
Sortiert wird auf zwei Wegen: mit dem Anfasser links ziehen, oder über die Pfeile **nach oben** und
**nach unten** an der Zeile. Der zweite Weg ist am Handy der bessere. Schritte werden innerhalb
ihres Abschnitts sortiert und lassen sich nicht in einen anderen ziehen.
Höchstens 200 Abschnitte, höchstens 500 Schritte je Abschnitt.
### Schritte und ihre Typen
Jeder Schritt hat **Titel**, **Typ** und das Häkchen **Pflichtschritt**. Dazu je nach Typ:
| Typ | Was der Techniker tut | Einstellungen |
|---|---|---|
| **Haken** | abhaken | keine |
| **Text** | Text eintragen | **Mehrzeiliges Feld** |
| **Zahl** | Zahl eintragen | **Mindestens**, **Höchstens**, **Einheit** |
| **Auswahl** | aus einer Liste wählen | **Optionen** (mindestens eine) |
| **Datum** | Datum wählen | keine |
| **Befehl** | Befehl kopieren und ausführen | **Shell** (powershell, bash, cmd), **Befehl** |
Mindestens und Höchstens sind **Hinweise, kein Verbot**: ein Wert außerhalb wird im Lauf markiert
und trotzdem gespeichert.
**Pflichtschritt** heißt: der Lauf lässt sich nicht abschließen, solange dieser Schritt weder
erledigt noch ausdrücklich als nicht zutreffend eingetragen ist. Setz das sparsam — sind alle
Schritte Pflicht, ist keiner Pflicht.
Beim Wechsel des Typs räumt der Editor die Einstellungen auf, damit nicht der Befehl eines
ehemaligen Befehlsschritts unsichtbar mitwandert.
### Hilfetext und Doku-Link
Der **Hilfetext** ist in der Abarbeitungsansicht ausklappbar und darf **Markdown** enthalten:
`**fett**`, Listen, Links, `` `Code` ``. **Kein HTML.** Hier steht das, was jemand beim ersten Mal
wissen muss — im Modus „Kompakt“ blendet der Techniker ihn später aus.
Der **Doku-Link** erscheint im Lauf als **Anleitung öffnen**. Nur `http`- und `https`-Adressen.
### Variablen
Variablen werden **beim Start eines Laufs abgefragt** und füllen die Platzhalter. **Variable
hinzufügen** legt eine an:
- **Schlüssel** — der Name im Platzhalter `{{schluessel}}`. Ein Variablenschlüssel beginnt mit einem
Kleinbuchstaben und besteht aus Kleinbuchstaben, Ziffern und Unterstrich, höchstens 40 Zeichen.
`vorname`, `ad_gruppe` und `raum2` gehen; `Vorname`, `mit-strich` und `2te_wahl` nicht. Die
Strenge hat einen Grund: derselbe Schlüssel steht in PowerShell-Befehlen, und `{{Vor-Name}}` ließe
sich dort nicht mehr von der Syntax der Shell trennen.
- **Beschriftung** — was im Startformular über dem Feld steht.
- **Typ** — Text, Zahl, Auswahl oder Datum.
- **Pflichtangabe beim Start** — ohne Eingabe lässt sich der Lauf nicht anlegen.
- **Optionen** — nur bei Auswahl.
Ein Schlüssel darf nur einmal vorkommen; der Editor markiert eine Doppelung sofort. Eine Variable,
die nirgends verwendet wird, ist erlaubt — sie wird trotzdem beim Start abgefragt.
Fünf Namen sind als Variable gesperrt: `techniker`, `datum`, `kunde`, `kundenkuerzel` und
`generiertes_passwort`. Sie sagen, **wer, wann und für wen** — eine Variable dafür hieße, dass in
einem fertigen Befehl ein fremder Name oder ein falsches Datum stehen kann, ohne dass es auffällt.
Höchstens 200 Variablen.
### Platzhalter
Ein Platzhalter `{{schluessel}}` in **Titel, Hilfetext oder Befehl** wird beim Abarbeiten ersetzt.
Leerzeichen in den Klammern sind erlaubt: `{{ vorname }}` wird erkannt.
Er wird aus einer Variablen der Vorlage gefüllt — oder aus einem der **eingebauten** Werte. Welche
es gibt, steht im Editor hinter **Welche Platzhalter gibt es?**, nach Quelle gruppiert:
| Quelle | Platzhalter |
|---|---|
| Gerät und Lauf | `hostname`, `seriennummer`, `inventarnummer`, `ticket`, `datum`, `techniker`, und im Modus Dienstleistung `kunde`, `kundenkuerzel` |
| Mitarbeiter | `mitarbeiter`, `benutzer`, `vorname`, `nachname`, `upn`, `sam`, `mail`, `abteilung`, `titel`, `vorgesetzter`, `vorgesetzter_sam`, `dn` |
| Abteilung | `ou` (Ziel-Container), `gruppen` (Standardgruppen, kommagetrennt) |
| Einstellungen | `ou_gesperrt` (Ziel-OU für deaktivierte Konten) |
| Besonders | `generiertes_passwort` |
`{{generiertes_passwort}}` ist der Sonderfall: **20 Zeichen, bei jeder Anzeige neu erzeugt und
nirgends gespeichert** — nicht im Lauf, nicht im Kommentar, nicht im Protokoll, nicht im Ausdruck.
Auf einem Ausdruck steht an seiner Stelle `[Passwort – nicht gespeichert]`. Bau also keinen Ablauf,
der davon ausgeht, dass man das Passwort später nachschlagen kann.
Ein Platzhalter, der zu keiner Variablen und keinem eingebauten Wert passt, wird oben im Editor
**gewarnt**, aber nicht abgewiesen: ein Tippfehler soll hier auffallen und nicht erst dem Techniker
im Lauf — es kann aber sein, dass die Variable gleich noch kommt. Im Lauf bleibt ein unbekannter
Platzhalter als Text stehen.
Für Befehlsschritte gilt eine zusätzliche Regel: **die Anführungszeichen gehören in die Vorlage,
nicht in den Wert.** Schreib also `New-ADUser -Name "{{vorname}} {{nachname}}"`. Steht ein
Platzhalter in einem Befehl nicht zwischen den erwarteten Anführungszeichen, wird der Schritt im
Lauf abgewiesen, statt einen halb maskierten Befehl zum Kopieren anzubieten.
### Vorschau
**Vorschau** zeigt die Vorlage so, wie sie im Lauf aussieht: mit Beispielwerten in allen
Platzhaltern, abgeschalteten Feldern und nummerierten Abschnitten. Gespeichert wird dabei nichts,
und für `{{generiertes_passwort}}` entsteht **kein** Passwort — dort steht
`BEISPIEL-PASSWORT-NICHT-ECHT`.
Nutz sie vor dem Veröffentlichen, besonders bei Befehlen: erst hier sieht man, ob der fertige Befehl
so aussieht, wie er aussehen soll.
## Veröffentlichen
Im Abschnitt **Veröffentlichen** trägst du eine **Änderungsnotiz (optional)** ein — ein Satz, was
sich gegenüber der letzten Version geändert hat; sie steht später in der Versionsgeschichte. Dann
**Veröffentlichen**.
**Veröffentlicht wird das Gespeicherte, nicht das, was auf dem Bildschirm steht.** Gibt es nicht
gespeicherte Änderungen, warnt der Editor — erst speichern, dann veröffentlichen.
Danach ist die Version unveränderlich, und Läufe können daran starten. Der Editor schaltet in den
Lesemodus.
Willst du weiter daran arbeiten, drückst du auf der Vorlagenseite wieder **Bearbeiten**: es entsteht
ein neuer Entwurf mit der nächsthöheren Nummer.
## Export und Import
**Als JSON exportieren** auf der Vorlagenseite lädt eine Version als Datei herunter. Das Dokument
enthält Kopfdaten, Variablen, Abschnitte und Schritte — und ausdrücklich keine Kennungen,
Zeitstempel, Läufe oder Kundenzuordnung. Damit lässt es sich in eine **andere**
Laufzettel-Installation einspielen.
Eingespielt wird in der Vorlagenliste unter **Vorlage aus einer Datei einspielen**: Datei wählen
oder den Inhalt einfügen, dann **Einspielen**. Zu wissen:
- Es entsteht immer eine **neue** Vorlage; der Import schreibt nie in eine bestehende hinein.
- Sie bekommt genau eine Version als **Entwurf**. Erst nach dem Ansehen und Veröffentlichen lässt
sich ein Lauf daran starten.
- Die Versionsnummer der Quelle wandert nicht mit: hier fängt die Vorlage wieder bei 1 an.
- Eine eingespielte Vorlage gilt zunächst für alle Kunden. Die Zuordnung setzt du danach in den
Kopfdaten.
- Schlägt die Prüfung fehl, wird **nichts** geschrieben, und die Meldung nennt die erste
Beanstandung mit ihrem Pfad, etwa
`struktur.abschnitte.1.schritte.2.titel: Ein Schritt braucht einen Titel.`
Das Format ist vollständig beschrieben in [docs/vorlagen-format.md](../vorlagen-format.md) — für
den Fall, dass jemand eine Vorlage von Hand schreiben oder ein Skript dafür bauen will.
## Vorlagen einem Gerätetyp zuordnen
Damit auf der Geräteseite ein **Schnellstart** erscheint, muss für den Gerätetyp eine Vorlage
hinterlegt sein — etwa „PC Neuinstallation“ für den Typ PC. Diese Zuordnung wird beim Einrichten der
Anwendung gesetzt und lässt sich in der Oberfläche noch nicht ändern; wende dich dafür an die
Person, die Laufzettel betreibt.
Ohne Zuordnung bleibt der Schnellstart leer, und das ist kein Mangel: unter **Vorlage zuweisen**
steht auf der Geräteseite jede Vorlage, die zu diesem Gerät passt, und der Start-Dialog kennt sie
ohnehin alle.
+178
View File
@@ -0,0 +1,178 @@
# Mitarbeiter, Abteilungen und Geräte
Unter **Mitarbeiter & Geräte** (im Modus Dienstleistung: **Personen & Geräte**) liegen die
Stammdaten. Oben schalten zwei Reiter um: **Personen** und **Geräte**.
Personen und Geräte pflegen darf die Rolle **Techniker**, **Redakteur** oder **Administrator** —
beim Onboarding steht der Techniker vor der Person, bevor der nächste Verzeichnisabgleich gelaufen
ist. **Abteilungen** bleiben beim **Administrator**: an ihnen hängen Ziel-OU und Standardgruppen,
also das, was die späteren PowerShell-Befehle in ein Verzeichnis schreiben.
## Personen
![Die Personenliste mit Suche und Kennzeichen je Person](bilder/mitarbeiter-liste-desktop.png)
Gesucht wird über **Name, Mail, UPN oder sam** — im Ticket steht mal das eine, mal das andere. Dazu
der Filter **Abteilung** und das Häkchen **auch deaktivierte**; ohne das erscheinen nur aktive
Personen.
Eine Zeile trägt den Namen, die Kennungen, die Abteilung und die Zahl der Geräte und Läufe. Zwei
Kennzeichen sind wichtig:
- **aus dem Verzeichnis** — diese Person kommt aus dem Active Directory. Ihre Stammdaten werden
dort gepflegt.
- **deaktiviert** — nicht mehr im Haus. Steht mit Durchstreichung da.
### Eine Person von Hand anlegen
Unter **Person von Hand anlegen**: **Vorname**, **Nachname**, **Mail-Adresse (optional)** und
**Abteilung (optional)** — für die Abteilung schlägt das Feld vorhandene Namen vor. Hinter
**Verzeichnis-Angaben (UPN, sAMAccountName, Titel)** stehen die drei Felder, die das Verzeichnis
sonst liefert. Im Modus Dienstleistung außerdem der **Kunde**. Dann **Person anlegen**.
Gedacht ist das für das Onboarding: die Person entsteht hier, das AD-Konto danach. Eine so angelegte
Person bleibt vollständig änderbar, und der nächste Verzeichnisabgleich **übernimmt** sie über UPN
oder sAMAccountName, statt sie ein zweites Mal anzulegen.
### Die Seite einer Person
![Die Seite einer Person mit Geräten und Stammdaten](bilder/mitarbeiter-seite-desktop.png)
Oben Name, Titel, Abteilung, gegebenenfalls der Vorgesetzte. Darunter:
- der Knopf **deaktivieren** beziehungsweise **wieder aktivieren**,
- **Geräte** — was dieser Person zugeordnet ist, mit Tag-Code,
- **Stammdaten** — das Formular, **Stammdaten speichern** übernimmt.
Kommt die Person **aus dem Verzeichnis**, ist jedes Feld der Stammdaten gesperrt, und oben steht,
warum: „Die Stammdaten werden dort gepflegt — eine Änderung hier würde der nächste Abgleich
zurücksetzen.“ Die Werte stehen trotzdem da, nur schreibgeschützt; ein ausgeblendetes Feld wäre eine
schlechtere Auskunft als ein gesperrtes. **Deaktivieren lässt sie sich trotzdem.**
![Eine Person aus dem Verzeichnis mit gesperrten Stammdaten](bilder/mitarbeiter-verzeichnis-desktop.png)
### Deaktivieren statt löschen
**Personen werden nie gelöscht, nur deaktiviert.** Ein archivierter Lauf muss auch in zehn Jahren
noch sagen können, für wen er lief. Eine deaktivierte Person verschwindet aus den Auswahllisten,
bleibt aber in jeder Historie auffindbar.
## Personen aus einer CSV-Datei einlesen
![Der CSV-Import für Personen mit Spaltenliste und Meldungen je Zeile](bilder/personen-import-desktop.png)
Unter **Personen aus einer CSV-Datei einlesen**. Die Spalten heißen wie im Verzeichnis, die
deutschen Entsprechungen gehen genauso:
| Spalte | Auch |
|---|---|
| `givenName` | vorname |
| `sn` | surname, nachname |
| `displayName` | anzeigename |
| `mail` | email, mailadresse |
| `upn` | userPrincipalName |
| `sam` | sAMAccountName |
| `department` | abteilung |
| `title` | titel |
Groß- und Kleinschreibung ist unerheblich, Trennzeichen darf Komma oder Semikolon sein, höchstens
500 Zeilen je Datei. Datei wählen oder Inhalt einfügen, im Modus Dienstleistung den **Kunden**
wählen, dann **Einlesen**.
Was der Import zusagt:
- **Wiedererkannt** wird eine Person über `sam` oder `upn`, sonst über den Anzeigenamen. Wer schon
da ist, wird aktualisiert statt ein zweites Mal angelegt.
- **Eine leere Zelle löscht nichts.** Was in der Datei nicht steht, bleibt, wie es ist.
- Fehlen `givenName` und `sn`, wird `displayName` am letzten Leerzeichen geteilt.
- **Personen aus dem Verzeichnis werden nicht überschrieben** — solche Zeilen werden gemeldet.
- Eine **deaktivierte** Person wird erkannt und aktualisiert, aber **nicht wieder aktiviert**. Dafür
gibt es den Schalter auf ihrer Seite.
- **Abteilungen legt der Import nicht an.** Der Name wird nur bei der Person eingetragen; als
Abteilung mit Ziel-OU und Standardgruppen übernimmt sie ein Administrator.
Eine fehlerhafte Zeile bricht den Import nicht ab. Sie wird unten mit ihrer **Zeilennummer** und dem
Grund gemeldet — genau die Zeilen sind in der Datei zu ändern.
## Abteilungen
Abteilungen liegen unter **Admin → Abteilungen**, weil an ihnen die Struktur des Verzeichnisses
hängt.
![Die Abteilungsliste mit Ziel-OU und Standardgruppen](bilder/abteilungen-desktop.png)
Eine Abteilung hat:
- **Name** — er muss **genauso lauten wie im Verzeichnis**, sonst findet die Zuordnung ihn nicht.
Verknüpft wird über den Abteilungsnamen der Person, nicht über eine Kennung.
- **Ziel-OU (optional)** — wo ein neues Konto landet, etwa
`OU=Buchhaltung,OU=Benutzer,DC=firma,DC=de`. Sie füllt den Platzhalter `{{ou}}`.
- **Standardgruppen, mit Komma getrennt** — sie füllen `{{gruppen}}`. **Die Reihenfolge bleibt
erhalten**, sie landet so im Gruppen-Befehl.
- **Mail-Domain (optional)** und **Notizen (optional)**.
Ein Tippfehler in der Ziel-OU legt ein Konto an der falschen Stelle an — das ist der Grund, warum
diese Seite dem Administrator gehört. Fehlen Ziel-OU und Standardgruppen, bleiben `{{ou}}` und
`{{gruppen}}` im Lauf leer.
**Bearbeiten** klappt das Formular an der Zeile auf. **archivieren** nimmt eine Abteilung aus dem
Angebot; gelöscht wird nichts, an einer Abteilung hängen Läufe. **wieder freigeben** macht es
rückgängig.
### Noch nicht gepflegte Abteilungen
![Vorschläge für Abteilungen, die bei Personen vorkommen, aber nicht gepflegt sind](bilder/abteilungen-vorschlaege-desktop.png)
Steht ein Abteilungsname bei Personen, ohne dass es dazu eine Abteilung gibt, erscheint oben
**Noch nicht gepflegt**. Ein Klick auf **Anlegen** legt die Abteilung mit diesem Namen an — **und
nur den Namen.** Ziel-OU, Standardgruppen und Mail-Domain sind danach leer und werden in der Liste
darunter nachgetragen.
## Geräte
![Die Geräteliste mit Filtern, Kennzeichen und Aktionen](bilder/geraete-liste-desktop.png)
Gesucht wird über **Hostname, Bezeichnung, Seriennummer, Tag-Code**, gefiltert nach **Typ** und mit
dem Häkchen **auch archivierte**.
Jede Karte zeigt Tag-Code, Namen, Typ, Besitzer, Modell, Serien- und Inventarnummer und die Zahl der
Läufe. Der Name führt auf die [Geräteseite](03-qr-tags.md). Kennzeichen: **Tag ohne Zuordnung** für
einen vorgedruckten Aufkleber, **archiviert** für ein Gerät außer Dienst.
**Stammdaten bearbeiten** klappt das Formular an der Karte auf: Hostname, Bezeichnung, Typ, Modell,
Seriennummer, Inventarnummer, Besitzer und Notizen. Zur Wahl als Besitzer stehen nur aktive Personen
desselben Kunden.
### Ein Gerät anlegen
Unter **Gerät anlegen**: **Hostname** und **Bezeichnung** (eines von beiden genügt), **Typ**,
**Modell**, **Seriennummer**, **Inventarnummer**, **Besitzer (optional)**, **Notizen (optional)**
und im Modus Dienstleistung der **Kunde**. Dann **Gerät anlegen**.
Der **Tag-Code entsteht dabei** und steht danach in der Erfolgsmeldung — er kommt auf den Aufkleber.
Drucke das Etikett gleich mit, solange du weißt, welches Gerät gemeint ist.
### Geräte aus einer CSV-Datei einlesen
Dieselbe Mechanik wie bei den Personen, höchstens 500 Zeilen. Spalten: `hostname`, `label`
(bezeichnung), `serialNumber`, `inventoryNumber`, `type`, `model`. **Eine der beiden Spalten
`hostname` oder `label` muss es geben** — sonst weiß niemand, was das Gerät ist. Jede eingelesene
Zeile verbraucht einen Tag-Code; lieber zweimal drucken als einmal zu viel einlesen.
### Archivieren statt löschen
**archivieren** nimmt ein Gerät aus dem Bestand: es taucht in keiner Auswahl mehr auf und nimmt
keine neue Arbeit an, bleibt aber in jeder Historie und lässt sich mit **wieder freigeben**
zurückholen. Das ist der normale Weg für ein ausgemustertes Gerät.
**löschen** ist die einzige echte Löschung in Laufzettel, und sie ist eng eingehegt:
- Sie erscheint nur, wenn **kein Lauf** an dem Gerät hängt. Sonst sagt Laufzettel: „An … hängen 3
Läufe. Das Gerät lässt sich deshalb nur archivieren.“
- Sie verlangt die Rolle **Administrator**.
- Sie fragt nach.
Der Grund für die Ausnahme: der Vertipper von eben stünde sonst für immer in der Liste. Der Preis
steht in der Rückfrage: **der Tag-Code bleibt für immer vergeben.** Er wird nie neu ausgegeben, ein
Aufkleber zeigt nie auf ein zweites Gerät — und ein schon gedruckter Aufkleber dieses Codes ist
danach wertlos.
+171
View File
@@ -0,0 +1,171 @@
# Verzeichnis anbinden
Unter **Admin → Verzeichnis** wird ein Active Directory als Quelle der Personen eingerichtet. Dann
muss beim Start eines Laufs nichts abgetippt werden, und `{{vorname}}`, `{{upn}}`, `{{sam}}` und
`{{abteilung}}` stehen von selbst zur Verfügung.
Zwei Dinge vorweg:
- Der Reiter gibt es **nur im Modus Intern**. Dort hat das Haus ein Verzeichnis, aus dem seine Leute
kommen. Im Modus Dienstleistung wären es Verzeichnisse fremder Kunden, und die bindet Laufzettel
ausdrücklich nicht an — dort kommen Personen von Hand oder per CSV.
- **Laufzettel schreibt nie ins Verzeichnis.** Ein Dienstkonto mit **Leserechten** genügt.
Gespeichert werden nur die zugeordneten Felder — keine Passwörter, keine
Gruppenmitgliedschaften.
Wer im Verzeichnis verschwindet oder dort deaktiviert wird, wird hier **inaktiv**. Gelöscht wird
nie, sonst verlöre die Historie ihre Namen.
## Eine Quelle anlegen
![Die Seite Verzeichnis mit Quelle, Status und Formular](bilder/verzeichnis-desktop.png)
Das Formular **Quelle anlegen** steht unten auf der Seite. Die Felder:
| Feld | Was hinein gehört |
|---|---|
| **Name** | wie du die Quelle nennst, etwa „Firmen-AD“ |
| **Adresse** | `ldaps://dc01.firma.local:636` — nur Schema, Rechner und Port |
| **Anmelde-DN** | das Dienstkonto, etwa `CN=laufzettel,OU=Dienstkonten,DC=firma,DC=local` |
| **Passwort** | wird verschlüsselt gespeichert und nie wieder angezeigt |
| **Basis-DN** | wo gesucht wird, etwa `OU=Benutzer,DC=firma,DC=local` |
| **Filter (optional)** | leer lassen nimmt den Standard: aktive Personenkonten |
| **Abgleich alle … Minuten** | zwischen 5 und 10080 (fünf Minuten bis eine Woche) |
| **Nach Zeitplan abgleichen** | das Häkchen schaltet den Zeitplan ein |
Für die **Adresse** gibt es drei Formen:
- `ldaps://host:636` — verschlüsselt ab dem ersten Byte. **Die richtige Wahl.**
- `ldap+starttls://host:389` — unverschlüsselt verbunden und dann hochgestuft.
- `ldap://host:389` — im Klartext. Sobald du das eintippst, warnt das Formular: dann geht auch das
Bind-Passwort im Klartext über das Netz. Für ein Dienstkonto im Active Directory ist das keine gute
Idee.
In die Adresse gehören **kein Pfad und keine Zugangsdaten**. Der Basis-DN hat sein eigenes Feld,
Bind-DN und Passwort ebenso; Laufzettel weist eine Adresse mit `/` oder `@` dahinter ausdrücklich ab.
Der **Standardfilter** schließt deaktivierte Konten aus. Das ist der Sinn: wer aus dem Ergebnis
verschwindet, wird hier deaktiviert. Ein eigener Filter engt weiter ein, etwa auf eine einzelne OU.
Dann **Quelle anlegen**.
## Attributzuordnung
Hinter **Attributzuordnung anpassen** steht, welches Verzeichnis-Attribut auf welches Feld geht. Die
Vorgaben sind die Namen eines Active Directory:
| Feld | Vorgabe |
|---|---|
| Vorname | `givenName` |
| Nachname | `sn` |
| Anzeigename | `displayName` |
| E-Mail | `mail` |
| UPN | `userPrincipalName` |
| Anmeldename (sam) | `sAMAccountName` |
| Abteilung | `department` |
| Titel | `title` |
| Vorgesetzter | `manager` |
| Kennung (dauerhaft) | `objectGUID` |
Ein OpenLDAP heißt an einigen Stellen anders — dort ist die dauerhafte Kennung etwa `entryUUID`.
Leer gelassene Felder bekommen die Vorgabe zurück.
> **Die Kennung ist das eine Feld, das später nicht mehr geändert werden sollte.** An ihr erkennt der
> Abgleich eine Person wieder. Ein anderer Wert legt beim nächsten Lauf **alle Personen neu an**.
## Eigenes CA-Zertifikat
Nur nötig, wenn der Domänencontroller ein Zertifikat aus einer eigenen CA trägt. Hinter **Eigenes
CA-Zertifikat hinterlegen** fügst du den **PEM**-Text ein, beginnend mit
`-----BEGIN CERTIFICATE-----`. Ohne Eintrag gelten die Wurzelzertifikate des Systems.
Ist eines hinterlegt, sagt die Seite das. Das Feld bleibt beim Bearbeiten trotzdem leer: **Text
eintragen ersetzt es, leer lassen behält es**, und das Häkchen **Hinterlegtes Zertifikat entfernen**
nimmt es weg. Beides zugleich ergibt keinen Sinn und wird abgewiesen.
Dasselbe gilt für das **Passwort**: leer lassen behält das gespeicherte. Sonst nähme jede
Namensänderung der Quelle ihre Anmeldung.
## Verbindung testen
![Das Ergebnis eines Verbindungstests: „Die Verbindung steht. 4 Einträge gelesen, 1 übersprungen“ und darunter die ersten drei Einträge mit UPN, Mail, DN, sam und Abteilung](bilder/verbindungstest-desktop.png)
**Verbindung testen** liest die ersten Einträge und zeigt sie so, wie sie ankämen — **gespeichert
wird nichts**. Mach das immer vor dem ersten Abgleich. Klappt es nicht, steht der Grund in Rot
darunter; die häufigsten stehen in [Kapitel 08](08-fehlerbehebung.md).
Der Wert steckt nicht im „geht“, sondern in der Liste darunter: erst an den zugeordneten Feldern
siehst du, ob die Zuordnung stimmt. Ein grünes „Verbindung erfolgreich“ sähe genauso aus, wenn `sam`
und `upn` vertauscht wären — und der Fehler fiele erst auf, wenn ein PowerShell-Befehl den falschen
Anmeldenamen trägt.
Prüf also Zeile für Zeile: Steht unter **UPN** wirklich der UPN? Unter **sam** der Anmeldename? Ist
die **Abteilung** genau der Name, unter dem du die Abteilung gepflegt hast?
Ein **Vorgesetzter** erscheint im Test nur, wenn er zufällig unter diesen Einträgen ist. Beim
vollständigen Abgleich wird er aufgelöst.
## Zeitplan und „Jetzt abgleichen“
Ist **Nach Zeitplan abgleichen** gesetzt, läuft der Abgleich von selbst im gewählten Intervall; an
der Quelle steht dann „alle 60 min“, sonst „kein Zeitplan“.
**Jetzt abgleichen** startet ihn sofort — nach einer Änderung am Filter will niemand bis zur nächsten
vollen Stunde warten. Es ist derselbe Vorgang, den auch der Zeitplan ruft, es gibt also keinen
zweiten Weg mit eigenen Regeln.
Läuft für diese Quelle gerade schon ein Abgleich, sagt Laufzettel das und tut nichts: „Für … läuft
gerade schon ein Abgleich. Bitte das Ergebnis abwarten.“
## Was der Abgleich tut
1. Er liest das Verzeichnis in Seiten von je tausend Einträgen.
2. Jeder Eintrag wird über die **Kennung** wiedererkannt und aktualisiert, oder neu angelegt.
3. Eine **von Hand angelegte** Person wird über `upn` oder `sam` gefunden und **übernommen**, nicht
verdoppelt. Genau so ist das Onboarding gedacht: die Person entsteht hier, das AD-Konto danach.
4. **Erst nach einem vollständigen Durchlauf** wird deaktiviert, wer nicht wiedergesehen wurde. Ein
Netzwerkfehler nach der halben Liste soll nicht die andere Hälfte der Firma abschalten.
Drei Sicherungen, die alle denselben Fehler verhindern — dass eine kaputte Eingabe wie „alle sind
gegangen“ aussieht:
- **Ließ sich kein einziger Eintrag zuordnen, gilt der Abgleich als gescheitert** und es wird niemand
deaktiviert. Die Meldung sagt: „Niemand wurde deaktiviert. Prüf die Attributzuordnung, besonders
das Kennungsattribut.“
- **Übersprungene Einträge sind nicht verschwunden** und werden aus der Abschaltung herausgenommen.
- Eine Person, die schon eine **andere** Quelle führt, wird nie übernommen.
Der Abgleich läuft nicht in einer Transaktion. Bricht er in der Mitte ab, bleiben die bis dahin
geschriebenen Personen stehen — ein zweiter Lauf holt den Rest.
## Das Abgleich-Log
![Das aufgeklappte Abgleich-Log mit Zeitpunkt, Bilanz und Fehlermeldung eines Laufs](bilder/abgleich-log-desktop.png)
An der Quelle steht der letzte Stand: „Zuletzt abgeglichen am …“ oder „Letzter Abgleich gescheitert“
mit der Meldung dazu.
Hinter **Abgleich-Log** stehen die letzten Läufe mit Zeitpunkt, wer sie ausgelöst hat (steht dort
**Zeitplan**, war es niemand von Hand — das ist der Regelfall) und der Bilanz:
| Zahl | Bedeutung |
|---|---|
| **gelesen** | Einträge, die aus dem Verzeichnis kamen |
| **angelegt** | neue Personen |
| **übernommen** | von Hand angelegte Personen, die jetzt am Verzeichnis hängen |
| **aktualisiert** | vorhandene Personen mit geänderten Feldern |
| **deaktiviert** | nicht mehr wiedergesehene Personen |
| **übersprungen** | Einträge, die sich nicht zuordnen ließen |
**Nullen werden gezeigt.** „0 angelegt, 0 aktualisiert“ ist die Auskunft, dass der Abgleich lief und
sich im Verzeichnis nichts geändert hat. Fehlte die Zeile, sähe ein erfolgreicher Lauf ohne Änderung
aus wie ein Lauf, der nie stattfand.
**Übersprungene** Einträge sind kein Fehler der Quelle, sondern einzelne krumme Konten — sie stehen
deshalb als Hinweis und nicht als Fehler da. Steigt die Zahl, prüf die Attributzuordnung.
## Ohne Verzeichnis
Eine Quelle ist keine Voraussetzung. Personen lassen sich von Hand anlegen oder aus einer CSV-Datei
einlesen — ein Verzeichnis nimmt nur die Arbeit ab. Siehe
[Kapitel 05](05-mitarbeiter-geraete.md).
+154
View File
@@ -0,0 +1,154 @@
# Verwaltung
Der Bereich **Admin** gehört der Rolle **Administrator**. Oben schalten Reiter um:
**Einstellungen**, **Benutzer**, **Abteilungen** und — nur im Modus Intern — **Verzeichnis**.
Abteilungen sind in [Kapitel 05](05-mitarbeiter-geraete.md) beschrieben, das Verzeichnis in
[Kapitel 06](06-verzeichnis.md).
## Benutzer und Rollen
![Die Benutzerliste mit Rollenwahl je Konto](bilder/admin-benutzer-desktop.png)
**Benutzer** sind die Menschen, die Laufzettel bedienen. Nicht zu verwechseln mit den Mitarbeitern
aus dem Verzeichnis: die melden sich hier nie an.
Je Konto stehen Name, E-Mail, die Anmeldewege (**Anbieter**, **Passwort** oder „kein Anmeldeweg“) und
der Zeitpunkt der letzten Anmeldung. Rechts:
- die **Rollenwahl** mit dem Knopf **Setzen**,
- der Schalter **aktiv** / **deaktiviert**.
Beim eigenen Konto steht statt des Schalters **das bist du** — niemand soll sich selbst aussperren.
Ein deaktiviertes Konto wird auch mit einem noch gültigen Sitzungs-Cookie abgewiesen, und eine
geänderte Rolle greift sofort: Rolle und Aktiv-Status werden bei jeder Anfrage aus der Datenbank
gelesen.
**Bei Anmeldung über den Anbieter wird die Rolle aus den Gruppen gesetzt und überschreibt eine hier
gewählte.** Wer eine Rolle dauerhaft ändern will, ändert sie also in der Gruppenzuordnung des
Anbieters, nicht hier.
Welche Rolle was darf, steht in [Kapitel 01](01-erste-schritte.md).
### Ein lokales Konto anlegen
Unter **Lokales Konto anlegen**: **Name**, **E-Mail**, **Rolle** und **Passwort (mindestens 12
Zeichen)**, dann **Konto anlegen**. Das Passwort wird nur als Hash gespeichert.
Gedacht ist das als **Notfallzugang**, falls der Anmeldeanbieter ausfällt. Ist der lokale Login
abgeschaltet, sagt die Seite das; neue Konten entstehen dann beim ersten Anmelden über den Anbieter
von selbst.
## Einstellungen
![Die Einstellungen mit Betriebsmodus, Organisation, Namensregeln und Sperr-OU](bilder/admin-einstellungen-desktop.png)
### Organisation
Der Name des eigenen Betriebs. Im Modus **Intern** erscheint er nirgends in der Oberfläche — er wird
erst sichtbar, wenn du auf Dienstleistung umstellst, denn dann wird daraus der erste Kunde.
### Namensregeln
Vorlagen für **UPN**, **Anmeldename (sam)**, **Mail** und **Hostname**. Beim Start eines Laufs
berechnet Laufzettel daraus Vorschläge, die der Techniker überschreiben kann — der Knopf dafür heißt
dort **Namen aus den Regeln vorschlagen**.
Platzhalter in den Regeln:
| Platzhalter | Bedeutung |
|---|---|
| `{vorname}` | der Vorname |
| `{nachname}` | der Nachname |
| `{v}` | der erste Buchstabe des Vornamens |
| `{nnn}` | eine fortlaufende Nummer |
| `{kundenkuerzel}` | das Kürzel des Kunden (nur Modus Dienstleistung) |
Beispiele: `{vorname}.{nachname}@firma.de` für den UPN, `{v}{nachname}` für den Anmeldenamen,
`PC-{nnn}` für den Hostname.
Ein Vorschlag ist ein Angebot, keine Zusage: **verbindlich wird ein Name erst beim Anlegen im
Verzeichnis.** Sind keine Regeln hinterlegt, sagt der Start-Dialog das und lässt die Felder leer.
### Sperr-OU
Die Ziel-OU für deaktivierte Konten, etwa `OU=Gesperrt,OU=Benutzer,DC=firma,DC=local`. Sie füllt den
Platzhalter `{{ou_gesperrt}}` und wird beim Offboarding gebraucht.
**Speichern** übernimmt alle drei Abschnitte auf einmal.
### Adresse für die QR-Codes
Ganz unten steht, welche Adresse in den gedruckten QR-Codes landet, als Beispiel ausgeschrieben:
`https://…/t/LZ-7K3F9Q`.
**Sie lässt sich hier nicht ändern.** Sie kommt aus der Umgebung der Anwendung, weil gedruckte
Aufkleber sonst ins Leere zeigen würden. Steht dort „APP_URL ist nicht gesetzt — Etiketten können
noch nicht gedruckt werden“, muss das erst beim Betrieb der Anwendung eingetragen werden. **Drucke
vorher keine Etiketten.**
## Betriebsmodus umstellen
![Der Bestätigungsdialog für den Moduswechsel mit den Folgen](bilder/admin-moduswechsel-desktop.png)
Oben auf der Einstellungsseite steht der aktuelle Modus mit einer Erklärung und darunter der Weg zum
anderen. Der Wechsel braucht keinen Neustart.
**Zu Dienstleistung wechseln** klappt einen Dialog auf, der die Folgen aufzählt — dieselbe Prüfung,
die der Wechsel danach durchsetzt, keine nachgebaute zweite Wahrheit:
- Die bisherige eigene Organisation wird zum **ersten Kunden**.
- Alle Geräte, Personen, Abteilungen und Läufe bleiben daran hängen.
- Kunden werden zu einer sichtbaren Ebene: Kundenliste, Kundenwahl beim Start eines Laufs,
Kundenkürzel auf den Etiketten.
Dafür trägst du **Name als erster Kunde** und **Kürzel (2–6 Zeichen)** ein, setzt das Häkchen „Ja,
den Betriebsmodus jetzt auf … umstellen“ und drückst **Auf Dienstleistung umstellen**.
Der Rückweg ist enger: **er geht nur mit genau einem nicht archivierten Kunden.**
![Der abgelehnte Moduswechsel mit der Begründung](bilder/admin-moduswechsel-abgelehnt-desktop.png)
Gibt es mehr, steht dort „Wechsel zu Intern gerade nicht möglich“ mit der Zahl und dem Hinweis, die
übrigen Kunden zu archivieren. Geht es, lauten die Folgen:
- Der einzige Kunde wird zur eigenen Organisation und **verschwindet aus der Oberfläche**.
- Alle Daten bleiben erhalten, nur die Kundenebene wird ausgeblendet.
- Archivierte Kunden bleiben archiviert und tauchen nicht wieder auf.
Der Wechsel ist also in beide Richtungen eine Datenänderung und kein Datenverlust. Trotzdem ist er
keine Kleinigkeit: er ändert, was die ganze Anwendung zeigt — deshalb das Häkchen.
## Kunden
![Die Kundenliste mit Kürzel, Zählung und der eigenen Organisation](bilder/kunden-desktop.png)
Den Menüpunkt **Kunden** gibt es nur im Modus Dienstleistung. Geräte, Personen, Abteilungen und
Läufe hängen an genau einem Kunden, und diese Grenze ist nicht nur eine Anzeige: ein Lauf kann kein
Gerät, keine Person und keine Abteilung eines fremden Kunden wählen.
Je Kunde stehen das **Kürzel**, der Name und die Zählung (Geräte, Personen, Läufe). Der eigene
Betrieb trägt das Kennzeichen **eigene Organisation**.
**Kunde anlegen** verlangt Name und Kürzel. Das **Kürzel erscheint auf den Etiketten** und lässt
sich in der Hostname-Regel als `{kundenkuerzel}` verwenden — wähl es also so, dass man es auf einem
Aufkleber erkennt.
**Bearbeiten** ändert Name, Kürzel und Notizen. **archivieren** nimmt einen Kunden außer Dienst;
gelöscht wird nichts, sonst verlöre das Archiv seine Bezüge. Die eigene Organisation lässt sich
nicht archivieren.
Eine Vorlage lässt sich **einem** Kunden zuordnen („nur Musterfirma GmbH“) oder für alle gelten
lassen. Eine kundenspezifische Vorlage startet keinen Lauf bei einem anderen Kunden.
## Was protokolliert wird
Laufzettel schreibt ein Protokoll über das, was nachvollziehbar bleiben muss: Rollenänderungen,
veröffentlichte Vorlagen, eingespielte Vorlagen, archivierte und zurückgeholte Läufe, angelegte und
gelöschte Verzeichnisquellen, den Moduswechsel. Ein Protokolleintrag verliert seinen Urheber nie. Eine
eigene Ansicht dafür gibt es in der Oberfläche noch nicht; was am Lauf selbst geschehen ist, steht in
seiner Abarbeitungsansicht und im Ausdruck.
Ein **erzeugtes Passwort steht nie im Protokoll** — es wird nirgends gespeichert. Gäbe es ein Feld
dafür, wäre die Versuchung groß, es „nur fürs Protokoll“ mitzuschreiben.
+229
View File
@@ -0,0 +1,229 @@
# Fehlerbehebung
Symptom, Ursache, was zu tun ist. Jede Meldung steht hier so, wie Laufzettel sie zeigt.
## „E-Mail-Adresse oder Passwort stimmt nicht.“
**Ursache:** falsche Zugangsdaten — **oder** ein deaktiviertes Konto. Die Meldung unterscheidet das
nicht, weil sie sonst verriete, welche Adressen es gibt.
**Was tun:** Zugangsdaten prüfen. Bist du sicher, dass sie stimmen, lass einen Administrator unter
**Admin → Benutzer** nachsehen, ob dein Konto auf **aktiv** steht. Ein deaktiviertes Konto wird auch
mit noch gültiger Anmeldung abgewiesen.
## „Der Anbieter war nicht erreichbar. Bitte später erneut versuchen.“
**Ursache:** der Anmeldeanbieter antwortet nicht.
**Was tun:** Gibt es ein lokales Konto, melde dich damit an — genau dafür ist es da. Sonst hilft nur
warten und die Störung melden. Der Notfallzugang steht unter **Admin → Benutzer → Lokales Konto
anlegen** und sollte eingerichtet sein, *bevor* der Anbieter ausfällt.
## „Dafür fehlt dir die Berechtigung“
**Ursache:** deine Rolle darf diese Seite nicht.
**Was tun:** In der Seitenleiste steht ohnehin nur, was du darfst — die Meldung kommt meist von einem
Link, den jemand weitergegeben hat. Brauchst du den Bereich wirklich, lass dir vom Administrator eine
stärkere Rolle geben. Kommt die Rolle vom Anmeldeanbieter, muss sie dort in der Gruppenzuordnung
geändert werden; eine hier gesetzte Rolle wird beim nächsten Anmelden überschrieben.
## „Deine Anmeldung gilt nicht mehr.“
**Ursache:** die Sitzung ist abgelaufen, während das Formular offen stand.
**Was tun:** Genau das, was die Meldung sagt: in einem **zweiten Tab** neu anmelden und dann hier
noch einmal klicken. Was du eingegeben hast, steht weiterhin auf der Seite — **lade sie nicht neu**,
das würde die Eingaben verwerfen.
## Ein Schritt lässt sich nicht speichern
**Ursache und was tun**, in dieser Reihenfolge prüfen:
1. **„… ist archiviert und damit schreibgeschützt.“** — der Lauf ist archiviert. Siehe unten.
2. **„Ein Schritt, der nicht zutrifft, braucht eine Begründung“** oder **„Ein fehlgeschlagener
Schritt braucht eine Notiz“** — füll das Feld **Begründung** und drück **Mit Begründung
speichern**. Ohne Begründung stünde im Archiv ein übersprungener Schritt ohne Grund.
3. **Die Pille springt zurück, ohne Meldung** — die Verbindung war weg. Tippe noch einmal. Jeder
Schritt speichert einzeln, es geht also nur dieser eine Klick verloren.
4. **Nichts passiert, gar nichts** — lade die Seite neu. Ist die Anmeldung abgelaufen, siehst du
danach die Anmeldeseite und kommst nach dem Anmelden auf dieselbe Stelle zurück.
5. **Deine Rolle ist Leser** — dann gibt es die Pillen nicht, sondern nur die Anzeige des Zustands.
Läufe bearbeiten darf erst der Techniker.
## Der Lauf lässt sich nicht abschließen
**Ursache:** es ist noch ein Pflichtschritt offen. Der Knopf ist dann gesperrt, und darunter steht,
wie viele es sind; die Meldung nennt die Titel: „Der Lauf lässt sich noch nicht abschließen. Offen
sind: …“
**Was tun:** Die genannten Schritte erledigen — oder, wenn sie nicht zutreffen, mit Begründung als
**nicht zutreffend** eintragen. **Ein fehlgeschlagener Schritt zählt nicht als erledigt:** er ist
entweder zu beheben oder mit Begründung als nicht zutreffend einzutragen. Trifft ein ganzer Abschnitt
nicht zu, nimm **Abschnitt trifft nicht zu** — eine Begründung für achtzehn Schritte.
## Der Lauf ist archiviert
**Ursache:** jemand hat ihn archiviert. Damit ist er schreibgeschützt: kein Haken, kein Wert, keine
Notiz, kein Kommentar geht mehr. Das ist die Zusage, für die das Archiv da ist.
**Was tun:** Prüf zuerst, ob du es wirklich brauchst. Für eine Ergänzung genügt oft ein neuer Lauf,
der auf den alten verweist — das ist der ehrlichere Weg, weil er datiert ist.
Muss es der alte Lauf sein, bittet du einen **Administrator**: nur er sieht auf der Laufseite **aus
dem Archiv zurückholen**. Der Lauf steht danach wieder auf „abgeschlossen“ und lässt sich ändern; das
Zurückholen wird protokolliert.
**Vorbeugen:** archiviere erst, wenn wirklich alles drinsteht. Es gibt keinen Grund zur Eile — ein
abgeschlossener Lauf steht auch schon im Archiv und ist durchsuchbar.
## „Kein Laufzettel-Tag“ nach dem Scannen
**Ursache:** einer von drei Fällen, und Laufzettel unterscheidet sie absichtlich nicht — die
Unterscheidung verriete, welche Codes es gibt:
- Es gibt den Code nicht.
- Der Code hat nicht die richtige Form.
- Der Code gehört zu einem **anderen Kunden**.
**Was tun:** Lies den Code unter dem QR ab und vergleich ihn. Ein Laufzettel-Code beginnt mit `LZ-`
und hat sechs Zeichen dahinter, **ohne** die Zeichen `0`, `O`, `1` und `I`. Wer `O` statt `Q` liest,
tippt einen Code, den es nicht geben kann — das ist Absicht, denn ein „verbessertes“ Zeichen könnte
auf ein anderes, vorhandenes Gerät führen.
Stimmt der Code und du siehst trotzdem nichts, gehört der Aufkleber zu einem Kunden, den du nicht
sehen darfst. Frag einen Administrator.
## Der Scanner startet nicht
| Meldung | Ursache | Was tun |
|---|---|---|
| „Die Kamera gibt es nur über HTTPS.“ | die Seite läuft unverschlüsselt | Die verschlüsselte Adresse verwenden. Bis dahin: Code eintippen. |
| „Die Kamera ist nicht freigegeben.“ | Berechtigung verweigert | In den Einstellungen des Browsers die Kamera für diese Seite freigeben und neu laden. |
| „Dieser Browser gibt keine Kamera frei.“ | kein Kamerazugriff möglich | Code eintippen. |
| „Der QR-Leser lässt sich nicht starten.“ | der Decoder ließ sich nicht laden | Seite neu laden. Bleibt es dabei, einem Administrator melden; bis dahin Code eintippen. |
| „Das ist kein Laufzettel-Tag.“ | ein fremder QR-Code im Bild | Den richtigen Aufkleber ins Bild halten — den mit `LZ-…`. |
In allen Fällen gilt: unter dem Kamerabild steht das Feld **Code eintippen**. Der Code steht lesbar
unter dem QR. Ein Scanner, der nicht startet, ist keine Sackgasse.
## Das Etikett wird nicht gelesen
**Ursache und was tun:**
1. **Falsch skaliert gedruckt.** Im Druckdialog **Skalierung auf 100 %** stellen und **Kopf- und
Fußzeilen abschalten**. „An Seite anpassen“ verkleinert den QR-Code und frisst die Ruhezone am
Rand, die der Leser braucht.
2. **Der Drucker hat gespart.** Manche Treiber lassen Flächen weg oder drucken sie grau. Ein QR-Code
braucht harten Schwarz-Weiß-Kontrast; stell den Farbmodus auf normal, nicht auf Toner-Sparen.
3. **Der Aufkleber ist beschädigt oder verschmutzt.** Tipp den Code darunter ein und drucke das
Etikett neu: auf der Geräteseite **Etikett drucken**. Der Code bleibt derselbe.
4. **Das Etikett sitzt neben der Stanzung.** Dann war die Skalierung falsch — siehe Punkt 1. Das
Raster des A4-Bogens füllt das Blatt genau aus und verträgt keinen Seitenrand.
5. **Der QR-Code führt ins Leere.** Dann war beim Drucken die Adresse der Anwendung nicht gesetzt
(die Druckansicht warnt davor in Rot). Ein geklebter Aufkleber lässt sich nicht berichtigen: die
Adresse eintragen lassen, Etiketten neu drucken und **überkleben**. Der Tag-Code selbst bleibt
gültig.
## „APP_URL ist nicht gesetzt“
**Ursache:** die Anwendung weiß nicht, unter welcher Adresse sie erreichbar ist. Die QR-Codes zeigen
dann auf `http://localhost:3000` und führen von keinem anderen Gerät irgendwohin.
**Was tun:** **Nicht drucken.** Ein Administrator trägt die Adresse in der Umgebung der Anwendung ein;
in der Oberfläche geht das nicht, weil gedruckte Aufkleber sonst ins Leere zeigen würden. Unter
**Admin → Einstellungen** steht ganz unten, welche Adresse gerade gilt.
## Der Verzeichnisabgleich meldet einen Fehler
| Meldung | Ursache | Was tun |
|---|---|---|
| „… ist keine LDAP-Adresse.“ | Schema fehlt oder falsch | `ldaps://host:636`, `ldap+starttls://host:389` oder `ldap://host:389` eintragen. |
| „In … fehlt der Rechnername.“ | nur das Schema eingetragen | Rechner und Port ergänzen. |
| „… enthält einen Pfad.“ | der Basis-DN steht in der Adresse | Adresse auf Schema, Rechner und Port kürzen; der Basis-DN hat sein eigenes Feld. |
| „… enthält Zugangsdaten.“ | `benutzer@host` in der Adresse | Bind-DN und Passwort in ihre eigenen Felder. |
| „Das Bind-Passwort fehlt.“ | leeres Passwortfeld beim Anlegen | Passwort eintragen; ein anonymer Bind wird nicht unterstützt. |
| Fehler beim Verbinden oder Anmelden | Netz, Firewall, Zertifikat, falsches Dienstkonto | Erreichbarkeit und Port prüfen. Bei einem Zertifikat aus eigener CA das **PEM** hinterlegen. Dann **Verbindung testen**. |
| „Niemand wurde deaktiviert. Prüf die Attributzuordnung, besonders das Kennungsattribut.“ | kein einziger Eintrag ließ sich zuordnen | Das ist die Notbremse: es wurde **nichts** abgeschaltet. Unter **Attributzuordnung anpassen** die **Kennung (dauerhaft)** prüfen — im Active Directory `objectGUID`, im OpenLDAP meist `entryUUID`. |
| „Für … läuft gerade schon ein Abgleich.“ | ein zweiter Lauf, oft der Zeitplan | Abwarten und das **Abgleich-Log** ansehen. |
| „Das CA-Zertifikat muss im PEM-Format vorliegen …“ | falscher Text eingefügt | Der Text muss mit `-----BEGIN CERTIFICATE-----` beginnen. |
**Übersprungene Einträge** im Log sind kein Fehler der Quelle, sondern einzelne krumme Konten. Steigt
die Zahl, prüf die Attributzuordnung.
**Der Abgleich lief, hat aber nichts getan** („0 angelegt, 0 aktualisiert“): das ist die Auskunft,
dass sich im Verzeichnis nichts geändert hat. Alles in Ordnung.
## „Der Entwurf wurde zwischenzeitlich von jemand anderem gespeichert.“
**Ursache:** zwei Redakteure am selben Entwurf.
**Was tun:** **Lade die Seite nicht neu** — deine Eingaben stehen noch da und wären danach weg. Öffne
den Entwurf in einem zweiten Tab, sieh dir die fremde Fassung an und trag deine Änderungen dort nach.
Wird es viel, hilft **Aufbau als JSON herauskopieren** unten an der Speicherleiste: damit rettest du
deinen Stand als Text heraus.
Denselben Ausweg gibt es bei **jedem** fehlgeschlagenen Speichern im Editor, egal woran es lag.
## Der Lauf zeigt einen Platzhalter wie `{{ad_gruppe}}`
**Ursache:** der Platzhalter gehört zu keiner Variablen und zu keinem eingebauten Wert. Im Lauf bleibt
er als Text stehen — sichtbar, aber nutzlos. Meist ein Tippfehler in der Vorlage.
**Was tun:** Im Befehl vor dem Ausführen von Hand ersetzen (der Kasten warnt davor: „konnte nicht
eingesetzt werden und steht so im Befehl“). Dann sag es dem Redakteur: im Editor steht die Warnung
oben, und die Vorlage braucht entweder die Variable oder den richtigen Schlüssel. Das wirkt erst in
der **nächsten** Version — laufende Läufe hängen an ihrer.
## Das Passwort ist weg
**Ursache:** `{{generiertes_passwort}}` wird bei jeder Anzeige neu erzeugt und **nirgends
gespeichert**. Sobald sich die Seite neu aufbaut — auch nach dem Speichern eines Schritts — steht dort
ein anderes. Auf dem Ausdruck steht `[Passwort – nicht gespeichert]`.
**Was tun:** Es gibt keinen Weg, es nachzuschlagen; es existiert nicht mehr. Setz im Zielsystem ein
neues Passwort und gib es weiter. Beim nächsten Mal: **kopieren und sofort weiterreichen**, bevor du
etwas anderes anklickst. Meldet der Streifen oben „Beim Neuladen entsteht ein neues Passwort“,
kopiere das angezeigte erst.
## Ein Gerät lässt sich nicht löschen
**Ursache:** an dem Gerät hängt mindestens ein Lauf — die Meldung nennt die Zahl. Ein Lauf muss für
immer nachvollziehbar bleiben. Oder deine Rolle ist nicht **Administrator**; nur er sieht den Knopf.
**Was tun:** **archivieren** statt löschen. Das Gerät verschwindet aus allen Auswahllisten und nimmt
keine neue Arbeit an, bleibt aber in jeder Historie — und lässt sich mit **wieder freigeben**
zurückholen.
Und bedenke: eine echte Löschung **verbrennt den Tag-Code für immer**. Ein schon gedruckter Aufkleber
dieses Codes ist danach wertlos.
## Eine Vorlage steht nicht zur Wahl
**Ursache:** eine von vier, in dieser Reihenfolge zu prüfen:
1. Sie hat **keine veröffentlichte Version** — ein Entwurf ist noch niemandes Arbeitsanweisung.
2. Sie ist **archiviert**.
3. Sie gehört **einem anderen Kunden** („nur Musterfirma GmbH“).
4. Auf der Geräteseite: sie verlangt **Pflichtangaben**. Dann steht sie mit dem Zusatz „fragt noch
nach Angaben“ dabei und führt in den Start-Dialog, statt sofort zu starten — hier wird ja niemand
gefragt.
**Was tun:** Fall 1 und 2 löst ein Redakteur auf der Vorlagenseite (**Veröffentlichen**
beziehungsweise **wieder freigeben**), Fall 3 ein Administrator über die Kundenzuordnung in den
Kopfdaten. Fall 4 ist kein Fehler: nimm den Start-Dialog.
## Ein Lauf oder ein Gerät ist nicht auffindbar
**Ursache und was tun:**
- **Die Liste ist gekappt.** Die Laufliste zeigt höchstens 200 Läufe, die Archivsuche höchstens 100
Treffer, die Gerätehistorie die letzten 20. Steht das dabei, such genauer statt zu blättern:
Titel, Ticket, Hostname, Person oder Tag-Code.
- **Der Lauf ist archiviert.** Archivierte Läufe stehen **nicht** in der Laufliste. Nimm das
**Archiv** — dort auch das Häkchen **auch offene und laufende Läufe**, wenn du beides zugleich
suchst.
- **Die Person ist deaktiviert.** Setz in der Personenliste das Häkchen **auch deaktivierte**.
- **Das Gerät ist archiviert.** Setz in der Geräteliste das Häkchen **auch archivierte**.
- **Es gehört einem anderen Kunden.** Dann ist es für dich nicht vorhanden, und daran ändert auch ein
weitergegebener Link nichts.
+55
View File
@@ -0,0 +1,55 @@
# Laufzettel — Anleitung
Laufzettel ist eine Checkliste für wiederkehrende IT-Arbeiten. Ein Redakteur schreibt einmal auf,
was bei einer PC-Neuinstallation, einem Onboarding oder einem Patchday zu tun ist — das ist eine
**Vorlage**. Aus einer veröffentlichten Vorlage entsteht ein **Lauf**: die Abschrift für einen
bestimmten Fall, mit einem bestimmten Gerät und einer bestimmten Person. Den Lauf arbeitet ein
Techniker Schritt für Schritt ab, oft am Handy, direkt am Gerät. Am Ende wird er abgeschlossen und
archiviert und ändert sich nie wieder.
Auf jedem Gerät klebt ein QR-Aufkleber. Wer ihn scannt, landet auf der Geräteseite und sieht, was
dort zu tun ist und was dort schon getan wurde. Der Code auf dem Aufkleber wird nie geändert und
nie an ein zweites Gerät vergeben — er ist die einzige Angabe am Gerät, die Jahre hält.
![Die Übersicht nach dem Anmelden mit den Blöcken „Überfällig“, „Mir zugewiesen“ und „Zuletzt bearbeitet“](bilder/uebersicht-desktop.png)
## Wer welches Kapitel braucht
**Techniker** — du arbeitest Läufe ab, scannst Aufkleber und druckst Etiketten:
- [01 Erste Schritte](01-erste-schritte.md) — anmelden, wo was steht
- [02 Läufe abarbeiten](02-laeufe-abarbeiten.md) — der Alltag, am Schreibtisch und am Handy
- [03 QR-Aufkleber und Tags](03-qr-tags.md) — scannen, registrieren, Etiketten drucken
- [08 Fehlerbehebung](08-fehlerbehebung.md) — wenn etwas klemmt
**Administrator oder Redakteur** — du richtest ein und pflegst:
- [04 Vorlagen bauen](04-vorlagen.md) — Abschnitte, Schritte, Variablen, veröffentlichen
- [05 Mitarbeiter, Abteilungen und Geräte](05-mitarbeiter-geraete.md) — Stammdaten und CSV-Import
- [06 Verzeichnis anbinden](06-verzeichnis.md) — Active Directory als Quelle der Personen
- [07 Verwaltung](07-admin.md) — Benutzer, Rollen, Einstellungen, Betriebsmodus, Kunden
## Zwei Betriebsmodi
Laufzettel läuft in einem von zwei Modi. Der Modus entscheidet, ob es eine Kundenebene gibt, und
ändert einige Beschriftungen.
| Modus | Für wen | Kundenebene |
|---|---|---|
| **Intern** | eine Firma und ihre eigenen Mitarbeiter | unsichtbar |
| **Dienstleistung** | IT-Dienstleister mit mehreren Kunden | sichtbar: Kundenliste, Kundenwahl, Kürzel auf dem Etikett |
Im Modus Intern heißt der Menüpunkt „Mitarbeiter & Geräte“, im Modus Dienstleistung „Personen &
Geräte“; dort kommt außerdem „Kunden“ dazu, und das Verzeichnis fällt weg. Wo diese Anleitung
„Person“ schreibt, heißt es im Modus Intern auch „Mitarbeiter“ — gemeint ist dasselbe. Umgestellt
wird der Modus unter Admin, siehe [Kapitel 07](07-admin.md).
## Drei Zusagen, die überall gelten
1. **Eine veröffentlichte Version wird nie verändert.** Sonst zeigte ein alter Lauf Schritte, die
es damals nicht gab. „Bearbeiten“ erzeugt stattdessen eine Kopie als Entwurf.
2. **Ein archivierter Lauf ist schreibgeschützt.** Kein Haken, keine Notiz, kein Kommentar geht
danach noch. Nur ein Administrator holt ihn zurück.
3. **Gelöscht wird fast nichts.** Geräte, Abteilungen, Vorlagen und Kunden werden archiviert,
Personen nur deaktiviert. Ein archivierter Lauf muss auch in zehn Jahren noch sagen können, für
wen er lief.
Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 376 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 376 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 800 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 435 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 605 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 510 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 485 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

+72 -2
View File
@@ -20,11 +20,12 @@ als Unterpunkte mit Aufgabe und Dauer.
| #015 | M10 – Dashboard und Archiv | 2 h 20 min | | #015 | M10 – Dashboard und Archiv | 2 h 20 min |
| #016 | M11 – Verzeichnis und LDAP-Abgleich | 3 h 25 min | | #016 | M11 – Verzeichnis und LDAP-Abgleich | 3 h 25 min |
| #017 | M12 – Seed der sieben Vorlagen | 1 h 30 min | | #017 | M12 – Seed der sieben Vorlagen | 1 h 30 min |
| | **Summe** | **27 h 40 min** | | #018 | M13 – E2E-Tests, Anleitung, PDF | 2 h 15 min |
| | **Summe** | **29 h 55 min** |
| Meilenstein | Tatsächlich | Status | | Meilenstein | Tatsächlich | Status |
|---|---|---| |---|---|---|
| v0.1.0 | 27 h 40 min | in Arbeit | | v0.1.0 | 29 h 55 min | in Arbeit |
--- ---
@@ -829,3 +830,72 @@ als Unterpunkte mit Aufgabe und Dauer.
[#021](issues/021-stabiler-schluessel-vorlagen.md) (stabiler Schlüssel für Seed-Vorlagen) [#021](issues/021-stabiler-schluessel-vorlagen.md) (stabiler Schlüssel für Seed-Vorlagen)
- #018 E2E-Tests, Anleitung und PDF — die Hauptabläufe in beiden Betriebsmodi, die bebilderte - #018 E2E-Tests, Anleitung und PDF — die Hauptabläufe in beiden Betriebsmodi, die bebilderte
Anleitung für Techniker und Admins und der PDF-Export über Chromium. Anleitung für Techniker und Admins und der PDF-Export über Chromium.
---
## 2026-09-26 · Sitzung 14 · 05:30–10:50
**Issue:** #018 (M13)
**Version:** 0.1.0
### Erledigt
- **Vier Teile parallel**: vorhandene Abläufe robust machen, vier neue Abläufe, die Anleitung, der
PDF-Export. Das Fundament — gemeinsamer Helfer, Vorabprüfung, Betriebsmodus — kam vorher von mir
- **Anleitung** unter `docs/anleitung/`: neun Kapitel, rund 11.600 Wörter, getrennt für Technik und
Verwaltung, mit 35 Bildern und einem Fehlerbehebungskapitel aus 18 echten Meldungen
- **`pnpm docs:pdf`**: 72 Seiten A4, Deckblatt, Inhaltsverzeichnis, Seitenzahlen, hell gedruckt,
ohne neue Abhängigkeit (`react-markdown` über `react-dom/server`)
- **Markierungen** in den Screenshots: Rahmen in der Akzentfarbe mit Nummer, für die Stellen, die
ein Kapitel gerade beschreibt
- **Vier neue Abläufe**: Hauptablauf von vorn bis hinten, Onboarding mit Zwischenablage-Vergleich,
Rollen, und 390 px auf achtzehn Seiten
- **101 Ende-zu-Ende-Abläufe** grün (vorher 67), Gesamtsuite unverändert grün
### Entscheidungen
- **Die Voraussetzungen eines Laufs werden geprüft, nicht behauptet.** `tests/e2e/global-setup.ts`
fragt vor dem ersten Ablauf: läuft ein gebauter Stand, steht der Seed-Bestand, und liest der
Server **dieselbe** Datenbank wie der Testprozess? Jede Meldung nennt die Ursache und was zu tun
ist. Die dritte Frage hat in #017 einen halben Abend gekostet.
- **Das PDF kommt nicht ins Repo.** Es entsteht aus Kapiteln und Bildern, die beide versioniert
sind, und würde bei jedem Lauf 8,7 MB neu anhängen. Der Auftrag sagt „liegt jedem Release bei“ —
genau so wird es gemacht.
- **Die `beispiel:*`-Skripte bleiben.** Sie sollten laut ihrem Kopfkommentar mit #017 verschwinden;
dort wurden zwei Dinge verwechselt. Der Seed richtet eine **Installation** ein, die Skripte
stellen her, was **Screenshots und Abläufe** brauchen: einen offenen Entwurf, einen halb
abgearbeiteten Lauf, eine Person aus dem Verzeichnis.
### Probleme
- **Das Projekt „handy" prüfte 412 px statt 390.** `devices["Pixel 7"]` ist 412 breit; CLAUDE.md und
das Abnahmekriterium nennen 390. Jeder „trägt am Handy ohne horizontales Scrollen"-Test lief damit
gegen eine Breite, die niemand verlangt hatte — zweiundzwanzig Pixel, die eine ausbrechende Seite
tadellos aussehen lassen. Nach der Korrektur blieb die Suite grün; die Zusage hielt also, sie war
nur nie geprüft.
- **Eine Auskunft, die niemand je gesehen hat.** Ein angemeldeter Benutzer ohne Recht wurde auf
`/anmelden?fehler=keinrecht` geschickt — und die Anmeldeseite schickt einen Angemeldeten sofort
weiter auf die Übersicht. Die Meldung „Für diesen Bereich fehlt dir die Berechtigung" war
unerreichbar, der Kommentar im Code versprach sie trotzdem. Jetzt gibt es `/kein-zugriff`.
- **Zwei Tests, die Schaden anrichteten.** Der Verzeichnis-Ablauf nahm die erste vorhandene Quelle
und schrieb ihre Attributzuordnung um — in einer echten Installation die produktive. Und ein
Nachlauf setzte die Namensregeln auf leer, statt den vorherigen Wert zurückzustellen: er löschte,
was der Seed eingerichtet hatte. Beides stammt aus meinen eigenen Meilensteinen.
- **Vier stille Annahmen**: Vorlage über die Position in der Liste, „die Datenbank enthält schon
Läufe", `test.skip` bei fehlenden Daten, und Fixtures im Kopfkommentar statt geprüft. Alle vier
fielen erst auf, als zum ersten Mal gegen eine **frisch eingerichtete** Installation geprüft
wurde.
- **Sechs Abläufe kannten die Kundenebene nicht.** Im Modus DIENSTLEISTUNG hat jedes Anlege-Formular
ein Feld mehr, und es ist Pflicht: `kundeErmitteln` wirft sonst. Schlimmer als das Feld ist die
Regel dahinter — ein Lauf kann strukturell kein Gerät und keine Abteilung eines fremden Kunden
wählen, dafür sorgen zusammengesetzte Fremdschlüssel. „Irgendein Kunde" ist dort nicht ungenau,
sondern rot. Beim Onboarding war es sogar eine Reihenfolge: die Kundenwahl stand hinter dem
Namensvorschlag, und ohne Kunden können die Namensregeln gar nicht greifen.
- **Parallele Arbeit an einem Verzeichnis geht schief.** Eine Wegwerf-Probe für den PDF-Bau hat die
`README.md` der Anleitung überschrieben, während sie geschrieben wurde. Wiederhergestellt, aber
der Ablageort für Proben gehört in das Verzeichnis für Zwischenstände, nicht neben die Arbeit.
### Nächster Schritt
- #019 Betriebswege und Release `v0.1.0` — `deploy/install.sh`, der Proxmox-One-Liner, die
Betriebsdokumentation und der Tag mit Release-Notes.
+71 -23
View File
@@ -1,9 +1,9 @@
# #018 M13 – E2E-Tests, Anleitung, PDF # #018 M13 – E2E-Tests, Anleitung, PDF
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.1.0 **Meilenstein:** v0.1.0
**Labels:** phase-1, test, docs **Labels:** phase-1, test, docs
**Geschätzt:** 14 h **Tatsächlich:** – **Geschätzt:** 14 h **Tatsächlich:** 2 h 15 min
## Ziel ## Ziel
@@ -11,27 +11,75 @@ Nachweis, dass die Abläufe wirklich durchlaufen, und eine Anleitung, mit der ei
## Aufgaben ## Aufgaben
- [ ] Playwright-Hauptablauf: anmelden → „PC Neuinstallation“ für `PC-001` starten → Schritte abhaken → Wert eintragen → einen Schritt „nicht zutreffend“ mit Notiz → abschließen → archivieren → im Archiv finden → Schreibschutz greift - [x] Playwright-Hauptablauf: anmelden → „PC Neuinstallation“ für `PC-001` starten → Schritte abhaken → Wert eintragen → einen Schritt „nicht zutreffend“ mit Notiz → abschließen → archivieren → im Archiv finden → Schreibschutz greift
- [ ] Derselbe Hauptablauf mit `SEED_MODE=SERVICE` gegen `MUS-PC-001`, mit Kundenwahl im Start-Dialog - [x] Derselbe Hauptablauf mit `SEED_MODE=SERVICE` gegen `MUS-PC-001`, mit Kundenwahl im Start-Dialog
- [ ] Playwright-QR-Ablauf: `/t/LZ-7K3F9Q` ohne Anmeldung → Login → Geräteseite → „Vorlage zuweisen“ → Lauf erscheint unter „Zugewiesen“ → „Starten“ setzt `IN_ARBEIT`; dazu unregistrierter Tag → Registrierformular → Geräteseite; unbekannter Tag → Hinweis - [x] Playwright-QR-Ablauf: `/t/LZ-7K3F9Q` ohne Anmeldung → Login → Geräteseite → „Vorlage zuweisen“ → Lauf erscheint unter „Zugewiesen“ → „Starten“ setzt `IN_ARBEIT`; dazu unregistrierter Tag → Registrierformular → Geräteseite; unbekannter Tag → Hinweis
- [ ] Playwright-Onboarding-Ablauf: Vorlage starten → Vorname und Nachname eingeben → UPN, `sam` und Mail werden vorgeschlagen → Abteilung „Buchhaltung“ wählen → im Schritt „Konto anlegen“ steht der fertige `New-ADUser`-Befehl → Kopieren liefert exakt diesen Text (Clipboard auslesen) - [x] Playwright-Onboarding-Ablauf: Vorlage starten → Vorname und Nachname eingeben → UPN, `sam` und Mail werden vorgeschlagen → Abteilung „Buchhaltung“ wählen → im Schritt „Konto anlegen“ steht der fertige `New-ADUser`-Befehl → Kopieren liefert exakt diesen Text (Clipboard auslesen)
- [ ] Playwright-Mobilprüfung: auf allen Hauptseiten bei 390 × 844 ist `scrollWidth <= clientWidth` - [x] Playwright-Mobilprüfung: auf allen Hauptseiten bei 390 × 844 ist `scrollWidth <= clientWidth`
- [ ] Playwright-Rollenprüfung: `LESER` sieht keine Schreib-Knöpfe und wird serverseitig abgewiesen - [x] Playwright-Rollenprüfung: `LESER` sieht keine Schreib-Knöpfe und wird serverseitig abgewiesen
- [ ] Die Abläufe suchen ihre Vorlage **am Namen**, nicht über `select ... { index: 1 }`. Aufgefallen in #017: gegen eine frisch geseedete Datenbank steht an erster Stelle „Mitarbeiter-Offboarding“ mit Pflichtvariablen, die der Helfer nicht füllt — sechs Abläufe liefen je 30 s in den Zeitablauf. Ein Ablauf, der von der Sortierung der Vorlagenliste abhängt, prüft nicht, was er zu prüfen vorgibt - [x] Die Abläufe suchen ihre Vorlage **am Namen**, nicht über `select ... { index: 1 }`. Aufgefallen in #017: gegen eine frisch geseedete Datenbank steht an erster Stelle „Mitarbeiter-Offboarding“ mit Pflichtvariablen, die der Helfer nicht füllt — sechs Abläufe liefen je 30 s in den Zeitablauf. Ein Ablauf, der von der Sortierung der Vorlagenliste abhängt, prüft nicht, was er zu prüfen vorgibt
- [ ] Die Voraussetzungen eines E2E-Laufs stehen an einer Stelle und werden geprüft, statt im Kopfkommentar zu stehen (`pnpm beispiel:vorlage`, `pnpm beispiel:stammdaten`, `pnpm beispiel:lauf`) — besser noch: die Abläufe legen an, was sie brauchen. Die drei Skripte tragen auch die Screenshots; was davon bleibt und was in die Abläufe wandert, wird hier entschieden. In den Seed gehört es **nicht**: eine frische Installation soll keine Demo-Läufe und keine offenen Entwürfe mitbringen (in #017 geprüft und so entschieden) - [x] Die Voraussetzungen eines E2E-Laufs stehen an einer Stelle und werden geprüft, statt im Kopfkommentar zu stehen (`pnpm beispiel:vorlage`, `pnpm beispiel:stammdaten`, `pnpm beispiel:lauf`) — besser noch: die Abläufe legen an, was sie brauchen. Die drei Skripte tragen auch die Screenshots; was davon bleibt und was in die Abläufe wandert, wird hier entschieden. In den Seed gehört es **nicht**: eine frische Installation soll keine Demo-Läufe und keine offenen Entwürfe mitbringen (in #017 geprüft und so entschieden)
- [ ] Kein Ablauf setzt voraus, dass die Datenbank schon Läufe enthält. `uebersicht-druck.spec.ts` sucht sich mit `lauf.findFirstOrThrow()` irgendeinen Lauf und „Zuletzt bearbeitet" muss gefüllt sein — gegen eine frisch eingerichtete Installation scheitert beides mit „No record was found", und das sagt nichts über die Ursache (gesehen in #017) - [x] Kein Ablauf setzt voraus, dass die Datenbank schon Läufe enthält. `uebersicht-druck.spec.ts` sucht sich mit `lauf.findFirstOrThrow()` irgendeinen Lauf und „Zuletzt bearbeitet" muss gefüllt sein — gegen eine frisch eingerichtete Installation scheitert beides mit „No record was found", und das sagt nichts über die Ursache (gesehen in #017)
- [ ] Der E2E-Lauf prüft beim Start, dass er **dieselbe** Datenbank benutzt wie der Server unter Test. Die Abläufe legen ihre Daten über `prisma` aus `.env` an, die Adresse des Servers steht in `E2E_APP_URL` — zeigen beide auf verschiedene Datenbanken, schreibt der Test in die eine und sucht in der anderen. Die Meldung heißt dann „0 Treffer“ und sagt nichts über die Ursache (gesehen in #017) - [x] `pnpm beispiel:stammdaten` und `pnpm beispiel:lauf` laufen nur im Modus `INTERN`: sie rufen die Dienste ohne Kunden und scheitern im Modus `DIENSTLEISTUNG` an `MandantFehler: Im Modus Dienstleistung muss ein Kunde gewählt werden`. Das ist richtiges Verhalten der App und eine Lücke der Skripte — wer den Hauptablauf in beiden Modi fahren will, braucht sie in beiden
- [ ] `pnpm docs:screenshots`: Playwright-Skript, Desktop 1440 × 900 und Handy 390 × 844, Seed-Daten, dunkles Design, mit Markierungen (Rahmen, Nummern) für die beschriebenen Stellen; Ablage `docs/anleitung/bilder/` - [x] Der E2E-Lauf prüft beim Start, dass er **dieselbe** Datenbank benutzt wie der Server unter Test. Die Abläufe legen ihre Daten über `prisma` aus `.env` an, die Adresse des Servers steht in `E2E_APP_URL` — zeigen beide auf verschiedene Datenbanken, schreibt der Test in die eine und sucht in der anderen. Die Meldung heißt dann „0 Treffer“ und sagt nichts über die Ursache (gesehen in #017)
- [ ] `pnpm docs:pdf`: Anleitung als PDF über Chromium (`page.pdf`, A4, `printBackground`), Ablage `docs/anleitung/laufzettel-anleitung.pdf` - [x] `pnpm docs:screenshots`: Playwright-Skript, Desktop 1440 × 900 und Handy 390 × 844, Seed-Daten, dunkles Design, mit Markierungen (Rahmen, Nummern) für die beschriebenen Stellen; Ablage `docs/anleitung/bilder/`
- [ ] `docs/anleitung/` auf Deutsch, getrennt für Techniker und Admins: Erste Schritte · Vorlagen bauen · Läufe abarbeiten (Desktop und Handy) · QR-Tags drucken und registrieren · Mitarbeiter und Abteilungen · Betriebsmodus · Admin: Verzeichnis, Benutzer, Einstellungen · Fehlerbehebung - [x] `pnpm docs:pdf`: Anleitung als PDF über Chromium (`page.pdf`, A4, `printBackground`), Ablage `docs/anleitung/laufzettel-anleitung.pdf`
- [ ] `README.md` vollständig: Setup, alle Umgebungsvariablen, Betrieb mit Docker Compose, authentik-Anbindung inkl. Gruppen für Rollen, Seed, Sicherungshinweis für die Datenbank - [x] `docs/anleitung/` auf Deutsch, getrennt für Techniker und Admins: Erste Schritte · Vorlagen bauen · Läufe abarbeiten (Desktop und Handy) · QR-Tags drucken und registrieren · Mitarbeiter und Abteilungen · Betriebsmodus · Admin: Verzeichnis, Benutzer, Einstellungen · Fehlerbehebung
- [x] `README.md` vollständig: Setup, alle Umgebungsvariablen, Betrieb mit Docker Compose, authentik-Anbindung inkl. Gruppen für Rollen, Seed, Sicherungshinweis für die Datenbank
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] Alle Playwright-Abläufe grün, in `INTERN` und in `DIENSTLEISTUNG` - [x] Alle Playwright-Abläufe grün, in `INTERN` und in `DIENSTLEISTUNG`
- [ ] Der Kopier-Knopf liefert exakt den erwarteten Befehlstext, zeichengenau geprüft - [x] Der Kopier-Knopf liefert exakt den erwarteten Befehlstext, zeichengenau geprüft
- [ ] Keine Hauptseite scrollt bei 390 px horizontal - [x] Keine Hauptseite scrollt bei 390 px horizontal
- [ ] `pnpm docs:screenshots` erzeugt alle in der Anleitung verlinkten Bilder reproduzierbar neu - [x] `pnpm docs:screenshots` erzeugt alle in der Anleitung verlinkten Bilder reproduzierbar neu
- [ ] `pnpm pruefe:doku` meldet kein fehlendes Bild - [x] `pnpm pruefe:doku` meldet kein fehlendes Bild
- [ ] Das PDF enthält alle Kapitel mit Bildern, Umlaute korrekt, keine abgeschnittenen Seiten - [x] Das PDF enthält alle Kapitel mit Bildern, Umlaute korrekt, keine abgeschnittenen Seiten
- [ ] Die README beschreibt authentik so, dass ein Dritter die Anbindung ohne Rückfrage hinbekommt - [x] Die README beschreibt authentik so, dass ein Dritter die Anbindung ohne Rückfrage hinbekommt
## Ergebnis
Erledigt am 2026-09-26. **101 Ende-zu-Ende-Abläufe** im Modus `INTERN` und **97** im Modus
`DIENSTLEISTUNG`, beide ohne Fehlschlag; Vitest unverändert bei 1130. Die Anleitung steht unter
`docs/anleitung/` mit 35 Bildern, das PDF hat 72 Seiten.
### Was die Abläufe jetzt anders machen
- **Die Voraussetzungen werden geprüft, nicht behauptet.** `tests/e2e/global-setup.ts` fragt vor dem
ersten Ablauf: antwortet ein gebauter Stand, steht der Seed-Bestand, und liest der Server
**dieselbe** Datenbank wie der Testprozess? Die dritte Frage hat in #017 einen halben Abend
gekostet, weil ihre Antwort „0 Treffer" hieß.
- **Jeder Ablauf legt an, was er braucht, und räumt es weg.** Keine Vorlage über `index: 1`, keine
Annahme „die Datenbank enthält schon Läufe", kein `test.skip` bei fehlenden Daten.
- **Zehn Kopien von `anmelden()`** sind zu einer geworden.
### Drei Fehler, die dabei herauskamen
1. **Das Projekt „handy" prüfte 412 px statt 390.** `devices["Pixel 7"]` ist 412 breit; CLAUDE.md und
das Abnahmekriterium nennen 390. Jeder „trägt am Handy ohne horizontales Scrollen"-Test lief
gegen eine Breite, die niemand verlangt hatte. Nach der Korrektur blieb alles grün — die Zusage
hielt, sie war nur nie geprüft.
2. **Eine Auskunft, die niemand je gesehen hat.** Ein angemeldeter Benutzer ohne Recht wurde auf
`/anmelden?fehler=keinrecht` geschickt, und die Anmeldeseite leitet einen Angemeldeten sofort
weiter. Die Meldung war unerreichbar, der Kommentar im Code versprach sie. Jetzt gibt es
`/kein-zugriff` mit der eigenen Rolle darin.
3. **Zwei Tests, die Schaden anrichteten:** der Verzeichnis-Ablauf schrieb die Attributzuordnung der
**ersten vorhandenen** Quelle um — in einer echten Installation die produktive; und ein Nachlauf
setzte die Namensregeln auf leer, statt den vorherigen Wert zurückzustellen.
### Der zweite Betriebsmodus
Der erste Durchgang im Modus `DIENSTLEISTUNG` hatte **28 rote Abläufe**, sechs Ursachen, alle
dieselbe blinde Stelle: die Kundenebene. Dort hat jedes Anlege-Formular ein Pflichtfeld mehr, und
dahinter steht eine Regel, die nicht verhandelbar ist — ein Lauf kann **strukturell** kein Gerät und
keine Abteilung eines fremden Kunden wählen, dafür sorgen zusammengesetzte Fremdschlüssel. „Irgendein
Kunde" ist dort nicht ungenau, sondern rot. Beim Onboarding war es sogar eine Reihenfolge: die
Kundenwahl stand hinter dem Namensvorschlag, und ohne Kunden greifen die Namensregeln nicht.
Ohne dieses Abnahmekriterium wäre der zweite Betriebsmodus ungeprüft ins Release gegangen.
### Beim Schreiben der Anleitung aufgefallen
Zwei Stellen, an denen die App etwas kann, das niemand bedienen kann — beide als Issue festgehalten,
nicht weggeschwiegen: [#022](022-schnellstart-pflegen.md) (Schnellstart je Gerätetyp setzt nur der
Seed) und [#023](023-protokoll-ansicht.md) (das Protokoll wird geschrieben, aber nie angezeigt).
+2
View File
@@ -26,6 +26,8 @@ Zwei erprobte Wege in den Betrieb und ein sauberes erstes Release.
- [ ] Devlog-Phasenzusammenfassung: Gesamtstunden, Stunden je Meilenstein, größte Zeitfresser, Abweichung vom Plan - [ ] Devlog-Phasenzusammenfassung: Gesamtstunden, Stunden je Meilenstein, größte Zeitfresser, Abweichung vom Plan
- [ ] `CHANGELOG.md` für `v0.1.0` mit Datum, Version in `package.json`, Tag `v0.1.0`, Gitea-Release mit Changelog-Auszug, Anleitung als PDF beigelegt - [ ] `CHANGELOG.md` für `v0.1.0` mit Datum, Version in `package.json`, Tag `v0.1.0`, Gitea-Release mit Changelog-Auszug, Anleitung als PDF beigelegt
- [ ] Die Anleitung als PDF (`pnpm docs:pdf`) dem Gitea-Release als Datei beilegen — im Repo liegt sie nicht, sie entsteht aus den Kapiteln und Bildern
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] `docker run --rm debian:13` mit `deploy/install.sh` installiert vollständig durch: Postgres läuft, Migration und Seed gelaufen, Dienst antwortet auf `/api/gesundheit` - [ ] `docker run --rm debian:13` mit `deploy/install.sh` installiert vollständig durch: Postgres läuft, Migration und Seed gelaufen, Dienst antwortet auf `/api/gesundheit`
+34
View File
@@ -0,0 +1,34 @@
# #022 Schnellstart je Gerätetyp pflegen
**Status:** offen
**Im Tracker:** [#34](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/34)
**Meilenstein:** v0.2.0
**Labels:** phase-2, frontend, backend
**Geschätzt:** 3 h **Tatsächlich:** –
## Ziel
Welche Vorlagen auf der Geräteseite als **Schnellstart** erscheinen, steht in `GeraetetypVorlage` —
und lässt sich nur über den Seed setzen. In der Oberfläche gibt es dafür nichts. Wer eine eigene
Vorlage als Schnellstart für Notebooks haben will, kommt nicht heran; wer eine der sieben
Seed-Vorlagen dort loswerden will, auch nicht.
Aufgefallen beim Schreiben der Anleitung in [#018](018-e2e-anleitung-pdf.md): Kapitel 03 beschreibt
den Schnellstart, konnte aber nicht sagen, wer ihn einrichtet.
## Aufgaben
- [ ] Admin-Seite (oder ein Abschnitt unter Admin → Einstellungen): je Gerätetyp eine sortierbare
Liste der Vorlagen, die dort erscheinen sollen
- [ ] Nur **veröffentlichte**, nicht archivierte Vorlagen zur Auswahl; die Reihenfolge ist die
Reihenfolge der Kacheln (`position`)
- [ ] Recht `verwaltung`, Änderung ins `Protokoll`
- [ ] Der Seed legt die Vorgaben weiter an, überschreibt aber nie eine im Betrieb geänderte
Zuordnung — dieselbe Regel wie für alles andere im Seed
- [ ] Anleitung Kapitel 03 und 07 nachziehen
## Akzeptanzkriterien
- [ ] Test: eine zugewiesene Vorlage erscheint als Kachel auf der Geräteseite des passenden Typs
- [ ] Test: eine archivierte Vorlage verschwindet aus dem Schnellstart, ohne dass jemand aufräumt
- [ ] Test: `LESER` und `TECHNIKER` dürfen nicht ändern
+35
View File
@@ -0,0 +1,35 @@
# #023 Protokoll ansehen
**Status:** offen
**Im Tracker:** [#35](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/35)
**Meilenstein:** v0.2.0
**Labels:** phase-2, frontend
## Ziel
Laufzettel schreibt ein Protokoll über alles, was nachvollziehbar bleiben muss — Rollenänderungen,
veröffentlichte Vorlagen, archivierte und zurückgeholte Läufe, Verzeichnisquellen, den Moduswechsel.
**Ansehen kann es niemand.** Es gibt keine Oberfläche dafür; wer wissen will, wer wann eine Rolle
geändert hat, braucht `psql`.
Aufgefallen beim Schreiben der Anleitung in [#018](018-e2e-anleitung-pdf.md).
**Geschätzt:** 4 h **Tatsächlich:** –
## Aufgaben
- [ ] Admin → Protokoll: Liste mit Zeitpunkt, Urheber, Aktion, betroffener Zeile, umblätterbar
- [ ] Filter nach Zeitraum, Aktion und Urheber; Volltext über die Aktion
- [ ] Die Mandantensicht gilt auch hier: ein Eintrag zu einem fremden Kunden ist nicht sichtbar
- [ ] Recht `verwaltung` — das Protokoll sagt, wer was getan hat, und ist damit selbst eine Auskunft
über Menschen
- [ ] Ein Eintrag ohne Urheber (Systemlauf, etwa der Zeitplan) wird als solcher gezeigt, nicht als
Lücke
- [ ] Am Lauf selbst: die Einträge dieses Laufs in seiner Abarbeitungsansicht, eingeklappt
## Akzeptanzkriterien
- [ ] Test: ein Eintrag eines fremden Kunden erscheint nicht
- [ ] Test: `TECHNIKER` kommt nicht auf die Seite
- [ ] Test: ein erzeugtes Passwort taucht in keinem Eintrag auf
- [ ] Am Handy kein horizontales Scrollen
+4 -2
View File
@@ -22,8 +22,10 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
| #015 | [M10 – Dashboard und Archiv](015-dashboard-archiv.md) | erledigt | v0.1.0 | 8 h | 2 h 20 min | | #015 | [M10 – Dashboard und Archiv](015-dashboard-archiv.md) | erledigt | v0.1.0 | 8 h | 2 h 20 min |
| #016 | [M11 – Verzeichnis und LDAP-Abgleich](016-verzeichnis-ldap.md) | erledigt | v0.1.0 | 12 h | 3 h 25 min | | #016 | [M11 – Verzeichnis und LDAP-Abgleich](016-verzeichnis-ldap.md) | erledigt | v0.1.0 | 12 h | 3 h 25 min |
| #017 | [M12 – Seed der sieben Vorlagen](017-seed-sieben-vorlagen.md) | erledigt | v0.1.0 | 8 h | 1 h 30 min | | #017 | [M12 – Seed der sieben Vorlagen](017-seed-sieben-vorlagen.md) | erledigt | v0.1.0 | 8 h | 1 h 30 min |
| #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) | erledigt | v0.1.0 | 14 h | 2 h 15 min |
| #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 | – |
| #020 | [Vorgesetzter auch für von Hand angelegte Personen](020-vorgesetzter-manuell.md) | offen | v0.2.0 | 3 h | – | | #020 | [Vorgesetzter auch für von Hand angelegte Personen](020-vorgesetzter-manuell.md) | offen | v0.2.0 | 3 h | – |
| #021 | [Stabiler Schlüssel für Seed-Vorlagen](021-stabiler-schluessel-vorlagen.md) | offen | v0.2.0 | 2 h | – | | #021 | [Stabiler Schlüssel für Seed-Vorlagen](021-stabiler-schluessel-vorlagen.md) | offen | v0.2.0 | 2 h | – |
| | **Summe** | | | **141 h** | **27 h 40 min** | | #022 | [Schnellstart je Gerätetyp pflegen](022-schnellstart-pflegen.md) | offen | v0.2.0 | 3 h | – |
| #023 | [Protokoll ansehen](023-protokoll-ansicht.md) | offen | v0.2.0 | 4 h | – |
| | **Summe** | | | **148 h** | **29 h 55 min** |
+18 -1
View File
@@ -4,6 +4,13 @@ const APP_URL = process.env["E2E_APP_URL"] ?? "http://127.0.0.1:3000";
export default defineConfig({ export default defineConfig({
testDir: "./tests/e2e", testDir: "./tests/e2e",
/*
* Prueft vor dem ersten Ablauf, dass die Voraussetzungen stimmen: ein gebauter
* Stand antwortet, der Seed-Bestand steht, und der Server liest **dieselbe**
* Datenbank wie dieser Prozess. Ohne das faellt jede fehlende Voraussetzung
* erst mitten im Lauf auf — als Zeitablauf oder als „0 Treffer“ (#018).
*/
globalSetup: "./tests/e2e/global-setup.ts",
// Die Ablaeufe aendern gemeinsame Daten, deshalb nacheinander. // Die Ablaeufe aendern gemeinsame Daten, deshalb nacheinander.
fullyParallel: false, fullyParallel: false,
workers: 1, workers: 1,
@@ -20,6 +27,16 @@ export default defineConfig({
}, },
projects: [ projects: [
{ name: "desktop", use: { ...devices["Desktop Chrome"], viewport: { width: 1440, height: 900 } } }, { name: "desktop", use: { ...devices["Desktop Chrome"], viewport: { width: 1440, height: 900 } } },
{ name: "handy", use: { ...devices["Pixel 7"] } }, {
/*
* **390 px, nicht 412.** `devices["Pixel 7"]` ist 412 × 839; CLAUDE.md und
* das Abnahmekriterium nennen 390. Zweiundzwanzig Pixel sind kein
* Rundungsfehler — eine Seite, die bei 390 ausbricht, sieht bei 412
* tadellos aus. Jeder „trägt am Handy ohne horizontales Scrollen“-Test lief
* damit gegen eine Breite, die niemand verlangt hatte (#018).
*/
name: "handy",
use: { ...devices["Pixel 7"], viewport: { width: 390, height: 844 } },
},
], ],
}); });
+571
View File
@@ -0,0 +1,571 @@
#!/usr/bin/env tsx
/**
* Baut die Kapitel unter docs/anleitung/ zu einem PDF zusammen:
* docs/anleitung/laufzettel-anleitung.pdf.
*
* pnpm docs:pdf
*
* Gerendert wird mit denselben Bausteinen, die auch die Hilfetexte in der App
* benutzen – `react-markdown` und `remark-gfm`, hier serverseitig ueber
* `react-dom/server` zu HTML. Gedruckt wird mit dem Chromium aus
* `@playwright/test`, der auch die Screenshots aufnimmt. Keine neue
* Abhaengigkeit, und damit auch keine zweite Markdown-Auslegung, die von der
* Oberflaeche abweichen koennte.
*
* **Hell, nicht dunkel.** Die App ist dunkel, ein Ausdruck ist es nie
* (CLAUDE.md, „Gestaltung“: Druckansichten und Etiketten immer hell). Der
* Akzent ist deshalb nicht die Signal-Lime, sondern der Akzent des hellen
* Modus aus `globals.css` – Lime auf Weiss liest niemand.
*
* **Fehlt ein verlinktes Bild, bricht der Lauf ab.** Ein PDF mit Luecken ist
* schlimmer als keines: es sieht fertig aus. Erst `pnpm docs:screenshots`,
* dann `pnpm docs:pdf`.
*/
import { readdir, readFile, writeFile } from "node:fs/promises";
import { extname, join, resolve } from "node:path";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { chromium } from "@playwright/test";
const WURZEL = resolve(import.meta.dirname, "..");
const QUELLE = join(WURZEL, "docs", "anleitung");
const ZIEL = join(QUELLE, "laufzettel-anleitung.pdf");
const TITEL = "Laufzettel";
const UNTERTITEL = "Anleitung für Technik und Verwaltung";
/** Ein Eintrag des Inhaltsverzeichnisses. */
type Eintrag = {
readonly ebene: 1 | 2;
readonly text: string;
readonly anker: string;
};
/** Ein eingelesenes Kapitel, schon zu HTML gerendert. */
type Kapitel = {
readonly datei: string;
/** Zweistellige Nummer aus dem Dateinamen, falls vorhanden – `README.md` hat keine. */
readonly nummer: string | undefined;
readonly anker: string;
readonly html: string;
readonly eintraege: readonly Eintrag[];
};
/** Der kleine Teil von hast, den dieses Skript anfasst. */
type HastKnoten = {
readonly value?: string;
readonly children?: readonly HastKnoten[];
};
const UMLAUTE: Record<string, string> = { ä: "ae", ö: "oe", ü: "ue", ß: "ss" };
/**
* Sprungmarke aus einer Ueberschrift. Umlaute fallen auf ASCII zusammen, damit
* ein Anker zitierbar bleibt – dieselbe Faltung wie bei Bezeichnern.
*/
function anker(text: string): string {
const gefaltet = text
.toLowerCase()
.replace(/[äöüß]/g, (zeichen) => UMLAUTE[zeichen] ?? zeichen)
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return gefaltet === "" ? "abschnitt" : gefaltet;
}
/** Der reine Text einer Ueberschrift, ohne `*`, `` ` `` und Verweise. */
function textAus(knoten: HastKnoten | undefined): string {
if (!knoten) return "";
if (typeof knoten.value === "string") return knoten.value;
return (knoten.children ?? []).map(textAus).join("");
}
function maskiere(text: string): string {
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
/**
* Kapitel in Lesereihenfolge: `README.md` zuerst, danach nach Dateinamen.
*
* Die Zahl der Kapitel ist offen – das Skript nimmt, was da liegt.
*/
async function kapitelDateien(): Promise<readonly string[]> {
const eintraege = await readdir(QUELLE, { withFileTypes: true }).catch(() => {
throw new Error(`Es gibt kein Verzeichnis ${QUELLE}. Erst die Kapitel schreiben.`);
});
const dateien = eintraege
.filter((eintrag) => eintrag.isFile() && eintrag.name.endsWith(".md"))
.map((eintrag) => eintrag.name)
.sort((a, b) => {
const rang = (name: string) => (name.toLowerCase() === "readme.md" ? 0 : 1);
return rang(a) - rang(b) || a.localeCompare(b, "de");
});
if (dateien.length === 0) {
throw new Error(
`In ${QUELLE} liegt kein Kapitel (*.md). Ohne Kapitel gibt es keine Anleitung – ` +
`erst die Kapitel schreiben, dann "pnpm docs:pdf".`,
);
}
return dateien;
}
const BILDTYPEN: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".svg": "image/svg+xml",
".webp": "image/webp",
".gif": "image/gif",
};
/**
* Liest jedes verlinkte Bild ein und liefert es als `data:`-URI.
*
* Eingebettet statt verlinkt, weil das PDF danach fuer sich steht und keine
* Rolle spielt, aus welchem Verzeichnis Chromium die Seite geladen hat.
* Fehlt ein Bild, bricht der Lauf ab und nennt jede fehlende Datei.
*/
async function bilderEinlesen(
kapitel: readonly { readonly datei: string; readonly text: string }[],
): Promise<Map<string, string>> {
const bilder = new Map<string, string>();
const fehlend = new Set<string>();
for (const { datei, text } of kapitel) {
for (const treffer of text.matchAll(/!\[[^\]]*\]\(\s*([^)\s]+)/g)) {
const quelle = treffer[1];
if (!quelle || /^(https?|data):/i.test(quelle)) continue;
if (bilder.has(quelle)) continue;
const pfad = resolve(QUELLE, decodeURI(quelle));
const typ = BILDTYPEN[extname(pfad).toLowerCase()];
if (!typ) {
fehlend.add(`${datei} → ${quelle} (unbekannter Bildtyp)`);
continue;
}
const bytes = await readFile(pfad).catch(() => undefined);
if (!bytes) {
fehlend.add(`${datei} → ${quelle}`);
continue;
}
bilder.set(quelle, `data:${typ};base64,${bytes.toString("base64")}`);
}
}
if (fehlend.size > 0) {
throw new Error(
`Diese Bilder fehlen, deshalb entsteht kein PDF:\n` +
[...fehlend].map((zeile) => ` - ${zeile}`).join("\n") +
`\nMit "pnpm docs:screenshots" neu aufnehmen (gebauter Stand, siehe CLAUDE.md).`,
);
}
return bilder;
}
/**
* Die Bausteine fuer ein Kapitel: Ueberschriften bekommen Sprungmarken und
* landen im Inhaltsverzeichnis, Bilder werden eingebettet, Verweise auf andere
* Kapitel werden zu Sprungmarken im selben Dokument.
*
* `eintraege` wird beim Rendern gefuellt. Das ist Absicht: so kann das
* Inhaltsverzeichnis nicht von den wirklich gesetzten Ankern abweichen – ein
* zweiter Durchlauf durch das Markdown waere eine zweite Auslegung.
*/
function bausteine(
kapitelAnker: string,
eintraege: Eintrag[],
bilder: ReadonlyMap<string, string>,
ankerJeDatei: ReadonlyMap<string, string>,
): Components {
return {
h1: ({ node, children }) => {
eintraege.push({ ebene: 1, text: textAus(node), anker: kapitelAnker });
return createElement("h1", null, children);
},
h2: ({ node, children }) => {
const marke = `${kapitelAnker}--${anker(textAus(node))}`;
eintraege.push({ ebene: 2, text: textAus(node), anker: marke });
return createElement("h2", { id: marke }, children);
},
/*
* Kein `figure`: `react-markdown` setzt ein Bild in einen Absatz, und ein
* `figure` in einem `p` beendet den Absatz – der Browser raeumt das auf,
* aber mit leeren Absaetzen im Druckbild. Ein `span` mit `display: block`
* tut dasselbe, ohne die Absatzgrenze zu brechen.
*/
img: ({ src, alt }) => {
const quelle = typeof src === "string" ? src : "";
const beschriftung = typeof alt === "string" ? alt : "";
return createElement(
"span",
{ className: "bild" },
createElement("img", { src: bilder.get(quelle) ?? quelle, alt: beschriftung }),
beschriftung === "" ? null : createElement("span", { className: "bildtext" }, beschriftung),
);
},
a: ({ href, children }) => {
const ziel = typeof href === "string" ? href : "";
const pfad = ziel.split("#")[0] ?? "";
const kapitel = ankerJeDatei.get(pfad.replace(/^\.\//, ""));
/*
* Ein Verweis auf ein Nachbarkapitel fuehrt im PDF an dessen Anfang – auch
* wenn er einen Abschnitt nennt. Die Anker im Markdown folgen der Zaehlung
* von Gitea, die Anker hier der ASCII-Faltung des Projekts; aus
* „#ueberblick“ und „#überblick“ wuerde sonst mal ein Treffer und mal ein
* toter Sprung. Der Kapitelanfang trifft immer.
*/
return createElement("a", { href: kapitel ? `#${kapitel}` : ziel }, children);
},
};
}
/**
* Die drei Schriften des Projekts, eingebettet als `data:`-URI.
*
* Nur die Latin-Schnitte: Umlaute, `ß`, Anfuehrungszeichen und `·` liegen alle
* darin, und ein zweiter Schnitt je Familie braeuchte `unicode-range`, um
* ueberhaupt etwas zu bewirken. Fehlt eine Datei, druckt Chromium mit der
* Systemschrift – das PDF entsteht trotzdem, nur sieht es fremd aus.
*/
async function schriftregeln(): Promise<string> {
const familien = [
{ familie: "Space Grotesk Variable", paket: "space-grotesk" },
{ familie: "Manrope Variable", paket: "manrope" },
{ familie: "JetBrains Mono Variable", paket: "jetbrains-mono" },
];
const regeln: string[] = [];
for (const { familie, paket } of familien) {
const pfad = join(
WURZEL,
"node_modules",
"@fontsource-variable",
paket,
"files",
`${paket}-latin-wght-normal.woff2`,
);
const bytes = await readFile(pfad).catch(() => undefined);
if (!bytes) {
console.warn(` Hinweis: ${paket} nicht gefunden, es druckt die Systemschrift.`);
continue;
}
regeln.push(
`@font-face{font-family:"${familie}";font-style:normal;font-weight:200 800;` +
`font-display:block;src:url(data:font/woff2;base64,${bytes.toString("base64")}) format("woff2")}`,
);
}
return regeln.join("\n");
}
/** Das Stilblatt des Ausdrucks: hell, eine Linie, ein Akzent. */
const STIL = `
:root {
/*
* Die Token des hellen Modus aus globals.css. Die Signal-Lime ist auf Weiss
* nicht lesbar; im hellen Modus tritt an ihre Stelle #5e8a00, und ein
* Ausdruck ist immer hell.
*/
--grund: #ffffff;
--flaeche: #f6f7f4;
--linie: #d9ddd4;
--text: #0b0d10;
--nebentext: #5b6472;
--akzent: #5e8a00;
}
* { box-sizing: border-box; }
html { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
body {
margin: 0;
background: var(--grund);
color: var(--text);
font-family: "Manrope Variable", ui-sans-serif, system-ui, sans-serif;
font-size: 10.5pt;
line-height: 1.55;
hyphens: auto;
}
h1, h2, h3, h4 {
font-family: "Space Grotesk Variable", ui-sans-serif, system-ui, sans-serif;
line-height: 1.2;
break-after: avoid;
}
h1 { font-size: 21pt; margin: 0 0 4mm; }
h2 { font-size: 14pt; margin: 8mm 0 2mm; padding-top: 2mm; border-top: 1px solid var(--linie); }
h3 { font-size: 11.5pt; margin: 5mm 0 1mm; }
h4 { font-size: 10.5pt; margin: 4mm 0 1mm; color: var(--nebentext); }
p, ul, ol, blockquote, table { margin: 0 0 2.5mm; }
ul, ol { padding-left: 6mm; }
li { margin: 0 0 1mm; }
li > p { margin: 0; }
a { color: var(--akzent); text-decoration: none; }
strong { font-weight: 700; }
code {
font-family: "JetBrains Mono Variable", ui-monospace, monospace;
font-size: 0.88em;
background: var(--flaeche);
border: 1px solid var(--linie);
border-radius: 3px;
padding: 0 0.3em;
}
pre {
font-family: "JetBrains Mono Variable", ui-monospace, monospace;
font-size: 8.5pt;
line-height: 1.45;
background: var(--flaeche);
border: 1px solid var(--linie);
border-radius: 3mm;
padding: 3mm;
margin: 0 0 3mm;
white-space: pre-wrap;
word-break: break-word;
break-inside: avoid;
}
pre code { background: none; border: 0; padding: 0; font-size: inherit; }
blockquote {
border-left: 2px solid var(--akzent);
padding: 0 0 0 3mm;
color: var(--nebentext);
}
table { border-collapse: collapse; width: 100%; font-size: 9.5pt; break-inside: avoid; }
th, td { border: 1px solid var(--linie); padding: 1.5mm 2mm; text-align: left; vertical-align: top; }
th { background: var(--flaeche); font-weight: 600; }
hr { border: 0; border-top: 1px solid var(--linie); margin: 5mm 0; }
/* Bilder: eine feine Linie, und nie ueber den Rand – auch nicht in der Hoehe. */
.bild { display: block; margin: 3mm 0 4mm; break-inside: avoid; text-align: center; }
.bild img {
display: block;
margin: 0 auto;
max-width: 100%;
max-height: 200mm;
width: auto;
height: auto;
border: 1px solid var(--linie);
border-radius: 2mm;
}
.bildtext {
display: block;
margin-top: 1.5mm;
font-size: 8.5pt;
color: var(--nebentext);
}
/* Deckblatt */
.deckblatt {
display: flex;
flex-direction: column;
justify-content: space-between;
height: 228mm;
break-after: page;
}
.deckblatt .marke {
font-family: "JetBrains Mono Variable", ui-monospace, monospace;
font-size: 9pt;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--nebentext);
}
.deckblatt .titel {
font-family: "Space Grotesk Variable", ui-sans-serif, system-ui, sans-serif;
font-size: 52pt;
font-weight: 700;
letter-spacing: -0.02em;
margin: 0;
}
.deckblatt .strich { width: 40mm; height: 3px; background: var(--akzent); margin: 4mm 0 5mm; }
.deckblatt .untertitel { font-size: 15pt; color: var(--nebentext); margin: 0; }
.deckblatt .angaben {
border-top: 1px solid var(--linie);
padding-top: 3mm;
font-size: 9.5pt;
color: var(--nebentext);
display: flex;
gap: 8mm;
}
.deckblatt .angaben span strong { color: var(--text); font-weight: 600; }
/* Inhaltsverzeichnis */
.inhalt { break-after: page; }
.inhalt ol { list-style: none; padding: 0; margin: 0; }
.inhalt a { color: var(--text); display: flex; gap: 3mm; }
.inhalt .ebene1 { margin-top: 3mm; font-weight: 600; }
.inhalt .ebene1 .nummer {
font-family: "JetBrains Mono Variable", ui-monospace, monospace;
color: var(--akzent);
min-width: 8mm;
}
.inhalt .ebene2 { padding-left: 11mm; color: var(--nebentext); font-size: 9.5pt; }
.inhalt .ebene2 a { color: var(--nebentext); }
/* Kapitel beginnen auf einer neuen Seite. */
.kapitel { break-before: page; }
.kapitel .kennung {
font-family: "JetBrains Mono Variable", ui-monospace, monospace;
font-size: 9pt;
letter-spacing: 0.16em;
color: var(--akzent);
display: block;
margin-bottom: 2mm;
}
`;
/**
* Die Fusszeile kommt aus einem eigenen Dokument, in das die eingebetteten
* Schriften nicht reichen – deshalb eine Systemschrift.
*/
const FUSSZEILE = `
<div style="width:100%;padding:0 18mm;font-family:system-ui,sans-serif;font-size:8pt;color:#5b6472;
display:flex;justify-content:space-between;align-items:center;">
<span>${TITEL} · ${UNTERTITEL}</span>
<span><span class="pageNumber"></span> / <span class="totalPages"></span></span>
</div>`;
function deckblattHtml(version: string, datum: string): string {
return `<section class="deckblatt">
<div>
<p class="marke">Handbuch</p>
</div>
<div>
<h1 class="titel">${maskiere(TITEL)}</h1>
<div class="strich"></div>
<p class="untertitel">${maskiere(UNTERTITEL)}</p>
</div>
<div class="angaben">
<span>Version <strong>${maskiere(version)}</strong></span>
<span>Stand <strong>${maskiere(datum)}</strong></span>
</div>
</section>`;
}
function inhaltHtml(kapitel: readonly Kapitel[]): string {
const zeilen = kapitel.flatMap((k) =>
k.eintraege.map((eintrag) => {
const nummer = eintrag.ebene === 1 ? (k.nummer ?? "·") : "";
return (
`<li class="ebene${eintrag.ebene}"><a href="#${maskiere(eintrag.anker)}">` +
`<span class="nummer">${maskiere(nummer)}</span>` +
`<span class="text">${maskiere(eintrag.text)}</span></a></li>`
);
}),
);
return `<section class="inhalt">
<h1>Inhalt</h1>
<ol>
${zeilen.map((zeile) => ` ${zeile}`).join("\n")}
</ol>
</section>`;
}
function seiteHtml(schriften: string, koerper: string): string {
return `<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<title>${maskiere(TITEL)} – ${maskiere(UNTERTITEL)}</title>
<style>
${schriften}
${STIL}
</style>
</head>
<body>
${koerper}
</body>
</html>`;
}
/**
* Seitenzahl aus dem erzeugten PDF, so gut es ohne Fremdpaket geht: Chromium
* schreibt den Seitenbaum unkomprimiert. Findet die Regel nichts, bleibt die
* Angabe weg, statt eine falsche Zahl zu melden.
*/
function seitenzahl(pdf: Uint8Array): number | undefined {
const text = Buffer.from(pdf).toString("latin1");
const zahlen = [...text.matchAll(/\/Type\s*\/Pages\b[^>]*?\/Count\s+(\d+)/g)].map((t) => Number(t[1]));
return zahlen.length > 0 ? Math.max(...zahlen) : undefined;
}
// ---- Ablauf ---------------------------------------------------------------
const dateien = await kapitelDateien();
const rohkapitel = await Promise.all(
dateien.map(async (datei) => ({ datei, text: await readFile(join(QUELLE, datei), "utf8") })),
);
const bilder = await bilderEinlesen(rohkapitel);
const ankerJeDatei = new Map(
rohkapitel.map(({ datei }) => [datei, `kapitel-${anker(datei.replace(/\.md$/, ""))}`]),
);
const kapitel: Kapitel[] = rohkapitel.map(({ datei, text }) => {
const kapitelAnker = ankerJeDatei.get(datei) ?? `kapitel-${anker(datei)}`;
const eintraege: Eintrag[] = [];
const html = renderToStaticMarkup(
createElement(
ReactMarkdown,
{
remarkPlugins: [remarkGfm],
components: bausteine(kapitelAnker, eintraege, bilder, ankerJeDatei),
},
text,
),
);
return {
datei,
nummer: /^(\d{2})-/.exec(datei)?.[1],
anker: kapitelAnker,
html,
eintraege,
};
});
const { version } = JSON.parse(await readFile(join(WURZEL, "package.json"), "utf8")) as {
version: string;
};
const datum = new Intl.DateTimeFormat("de-DE", {
day: "numeric",
month: "long",
year: "numeric",
timeZone: "Europe/Berlin",
}).format(new Date());
const koerper = [
deckblattHtml(version, datum),
inhaltHtml(kapitel),
...kapitel.map(
(k) =>
`<section class="kapitel" id="${maskiere(k.anker)}">` +
(k.nummer ? `<span class="kennung">Kapitel ${maskiere(k.nummer)}</span>` : "") +
`${k.html}</section>`,
),
].join("\n");
const html = seiteHtml(await schriftregeln(), koerper);
const browser = await chromium.launch();
try {
const seite = await browser.newPage();
// Hell, und zwar ausdruecklich: die App richtet sich sonst nach dem Schema
// des Systems und brachte ihre dunklen Flaechen mit in den Druck.
await seite.emulateMedia({ colorScheme: "light", media: "print" });
await seite.setContent(html, { waitUntil: "load" });
await seite.evaluate(() => document.fonts.ready);
const pdf = await seite.pdf({
format: "A4",
printBackground: true,
margin: { top: "18mm", right: "18mm", bottom: "20mm", left: "18mm" },
displayHeaderFooter: true,
headerTemplate: "<span></span>",
footerTemplate: FUSSZEILE,
});
await writeFile(ZIEL, pdf);
const seiten = seitenzahl(pdf);
console.log(` ${ZIEL.replace(`${WURZEL}/`, "")}`);
console.log(
` ${kapitel.length} Kapitel, ${bilder.size} Bilder, ` +
`${(pdf.byteLength / 1024 / 1024).toFixed(1)} MB` +
(seiten === undefined ? "" : `, ${seiten} Seiten`),
);
} finally {
await browser.close();
}
console.log("Anleitung als PDF erzeugt.");
+110 -1
View File
@@ -54,6 +54,20 @@ type Seite = {
* wie nebenan, nur groesser. * wie nebenan, nur groesser.
*/ */
readonly ausschnitt?: (tab: Page) => Locator; readonly ausschnitt?: (tab: Page) => Locator;
/**
* Bereiche, die im Bild hervorgehoben werden – Rahmen in der Signal-Lime mit
* einer Nummer, auf die der Text der Anleitung sich beziehen kann (#018).
* Ohne Angabe entsteht genau dasselbe Bild wie vorher.
*/
readonly markierungen?: readonly Markierung[];
};
/**
* Eine Markierung im Bild. Die Nummer kommt aus der Reihenfolge – sie im Text
* der Anleitung zu nennen ist der ganze Zweck („(1) zeigt den Fortschritt“).
*/
type Markierung = {
readonly ziel: (tab: Page) => Locator;
}; };
/** Seiten, die abgebildet werden. Waechst mit jedem Meilenstein. */ /** Seiten, die abgebildet werden. Waechst mit jedem Meilenstein. */
@@ -137,7 +151,16 @@ const SEITEN: readonly Seite[] = [
}, },
{ name: "geraete-liste", pfad: "/geraete" }, { name: "geraete-liste", pfad: "/geraete" },
{ name: "laeufe-liste", pfad: "/laeufe" }, { name: "laeufe-liste", pfad: "/laeufe" },
{ name: "lauf-starten", pfad: "/laeufe/starten" }, {
name: "lauf-starten",
pfad: "/laeufe/starten",
// (1) Vorlage, (2) Geraet – die zwei Felder, ueber die das Kapitel „Lauf
// starten“ redet; alles darunter ist freiwillig.
markierungen: [
{ ziel: (tab) => tab.locator('select[name="vorlagenVersionId"]') },
{ ziel: (tab) => tab.locator('select[name="geraetId"]') },
],
},
{ {
name: "lauf-abarbeiten", name: "lauf-abarbeiten",
pfad: "/laeufe", pfad: "/laeufe",
@@ -148,6 +171,24 @@ const SEITEN: readonly Seite[] = [
.click(); .click();
await tab.waitForURL(/\/laeufe\/[^/]+$/); await tab.waitForURL(/\/laeufe\/[^/]+$/);
}, },
// (1) Fortschritt, (2) Zustandspillen des ersten Schritts, (3) Kommentare.
markierungen: [
{ ziel: (tab) => tab.getByRole("progressbar") },
{
ziel: (tab) =>
tab
.getByRole("listitem")
.filter({ has: tab.getByRole("button", { name: "erledigt", exact: true }) })
.first()
.getByRole("button", { name: "erledigt", exact: true })
// Nicht die Pille allein, sondern die Zeile mit allen drei Zustaenden.
.locator(".."),
},
{
ziel: (tab) =>
tab.locator("section").filter({ has: tab.getByRole("heading", { name: "Kommentare zum Lauf" }) }),
},
],
}, },
{ {
// Der Befehlsschritt, aufgeklappt und in den Blick gescrollt: am Handy // Der Befehlsschritt, aufgeklappt und in den Blick gescrollt: am Handy
@@ -176,6 +217,15 @@ const SEITEN: readonly Seite[] = [
await tab.getByRole("link", { name: "NB-001" }).first().click(); await tab.getByRole("link", { name: "NB-001" }).first().click();
await tab.waitForURL(/\/geraete\/[^/]+$/); await tab.waitForURL(/\/geraete\/[^/]+$/);
}, },
// (1) der Weg zum QR-Code, (2) was schon zugewiesen ist,
// (3) der Weg zu einer weiteren Vorlage.
markierungen: [
// Der Code selbst steht in einem zugeklappten `details` und waere im Bild
// nicht zu sehen – markiert wird deshalb der Griff, der ihn aufklappt.
{ ziel: (tab) => tab.locator("summary", { hasText: "QR-Code anzeigen" }) },
{ ziel: (tab) => tab.getByRole("heading", { name: "Zugewiesen" }).locator("..") },
{ ziel: (tab) => tab.getByRole("heading", { name: "Vorlage zuweisen" }).locator("..") },
],
}, },
{ {
/* /*
@@ -340,6 +390,64 @@ async function kachelnErzeugen(browser: Browser): Promise<void> {
} }
} }
/**
* Legt die Markierungen als Overlay in die Seite, bevor das Bild entsteht.
*
* Gezeichnet wird im Browser und in Dokumentkoordinaten (`getBoundingClientRect`
* plus `scrollX`/`scrollY`), damit der Rahmen auch auf einem `fullPage`-Bild dort
* sitzt, wo er hingehoert. Nachbearbeitet wird kein Bild – wer die Oberflaeche
* aendert, laesst dieses Skript erneut laufen, und die Rahmen wandern mit.
*
* Findet eine Markierung ihr Ziel nicht, bricht der Lauf ab: ein Bild mit
* fehlender „(2)“ macht den Text der Anleitung falsch.
*/
async function markierungenEinspritzen(
tab: Page,
seitenname: string,
markierungen: readonly Markierung[],
): Promise<void> {
for (const [stelle, markierung] of markierungen.entries()) {
const ziel = markierung.ziel(tab).first();
if ((await ziel.count()) === 0) {
throw new Error(`Markierung ${stelle + 1} von "${seitenname}" findet ihr Ziel nicht.`);
}
await ziel.evaluate((element, nummer) => {
const kasten = element.getBoundingClientRect();
const links = kasten.left + window.scrollX;
const oben = kasten.top + window.scrollY;
const rahmen = document.createElement("div");
Object.assign(rahmen.style, {
position: "absolute",
left: `${links - 5}px`,
top: `${oben - 5}px`,
width: `${kasten.width + 10}px`,
height: `${kasten.height + 10}px`,
border: "2px solid #C6F135",
borderRadius: "14px",
pointerEvents: "none",
zIndex: "2147483647",
});
const marke = document.createElement("div");
marke.textContent = String(nummer);
Object.assign(marke.style, {
position: "absolute",
// Am linken Rand rutscht die Nummer nach innen, sonst schnitte sie das Bild ab.
left: links < 24 ? "4px" : "-14px",
top: "-14px",
width: "26px",
height: "26px",
borderRadius: "999px",
background: "#C6F135",
color: "#0B0D10",
font: "700 15px/26px ui-sans-serif, system-ui, sans-serif",
textAlign: "center",
});
rahmen.append(marke);
document.body.append(rahmen);
}, stelle + 1);
}
}
const zielMeilenstein = argument("ziel"); const zielMeilenstein = argument("ziel");
const nur = argument("nur"); const nur = argument("nur");
/** Welcher Betriebsmodus gerade eingestellt ist – steuert, welche Seiten passen. */ /** Welcher Betriebsmodus gerade eingestellt ist – steuert, welche Seiten passen. */
@@ -376,6 +484,7 @@ try {
// Schriften muessen geladen sein, sonst wackelt das Bild zwischen Laeufen. // Schriften muessen geladen sein, sonst wackelt das Bild zwischen Laeufen.
await tab.evaluate(() => document.fonts.ready); await tab.evaluate(() => document.fonts.ready);
await tab.waitForLoadState("domcontentloaded"); await tab.waitForLoadState("domcontentloaded");
if (seite.markierungen) await markierungenEinspritzen(tab, seite.name, seite.markierungen);
const datei = join(ziel, `${seite.name}-${ansicht.name}.png`); const datei = join(ziel, `${seite.name}-${ansicht.name}.png`);
if (seite.ausschnitt) { if (seite.ausschnitt) {
+67
View File
@@ -0,0 +1,67 @@
import Link from "next/link";
import { ShieldAlert } from "lucide-react";
import { ROLLEN_TEXT } from "@/lib/rollen-text";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
/**
* „Dafür fehlt dir die Berechtigung“ — für jemanden, der **angemeldet** ist (#018).
*
* Vorher schickte `sitzungErforderlich` ihn auf `/anmelden?fehler=keinrecht`, und
* die Anmeldeseite leitete einen Angemeldeten sofort auf die Übersicht weiter:
* die Erklärung war unerreichbar, er stand ohne Hinweis da und wusste nicht, ob
* er sich verklickt hatte oder ob etwas kaputt ist. Der Kommentar in
* `sitzungspflicht.ts` versprach „eine Weiterleitung mit Erklärung“ — die
* Erklärung fehlte.
*
* Die Seite sagt deshalb drei Dinge: dass es an der Rolle liegt, welche Rolle er
* hat, und wohin er stattdessen kann. Sie nennt **nicht**, welche Rolle nötig
* wäre — das ist eine Auskunft über die Rechteverteilung, und wer sie braucht,
* fragt den Administrator.
*/
export default async function KeinZugriffSeite({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const { benutzer } = await sitzungErforderlich(undefined, "/kein-zugriff");
const parameter = await searchParams;
const roh = parameter["bereich"];
const bereich = (Array.isArray(roh) ? roh[0] : roh)?.trim();
// Nur eigene Pfade anzeigen: ein fremder Wert in der Adresse waere sonst ein
// Text, den jemand anderes auf diese Seite schreibt.
const gezeigt = bereich?.startsWith("/") && !bereich.startsWith("//") ? bereich : undefined;
return (
<div className="mx-auto flex max-w-prose flex-col items-start gap-5 py-10">
<ShieldAlert className="size-8 text-faellig" strokeWidth={1.75} aria-hidden="true" />
<h1 className="text-2xl font-bold">Dafür fehlt dir die Berechtigung</h1>
<p className="text-nebentext">
{gezeigt ? (
<>
Der Bereich <span className="mono break-all">{gezeigt}</span> ist für deine Rolle nicht
freigegeben.
</>
) : (
"Dieser Bereich ist für deine Rolle nicht freigegeben."
)}{" "}
Du bist angemeldet als <strong>{benutzer.name ?? benutzer.email}</strong> mit der Rolle{" "}
<strong>{ROLLEN_TEXT[benutzer.rolle]}</strong>.
</p>
<p className="text-sm text-nebentext">
In der Navigation steht nur, was du darfst — diese Meldung kommt meist von einem Link, den jemand
weitergegeben hat. Brauchst du den Bereich wirklich, wende dich an einen Administrator. Kommt deine
Rolle vom Anmeldeanbieter, wird sie dort in der Gruppenzuordnung geändert und bei der nächsten
Anmeldung übernommen.
</p>
<Link
href="/"
className="flex min-h-11 w-fit items-center rounded-pille bg-akzent px-5 text-sm font-semibold text-auf-akzent md:min-h-10"
>
Zur Übersicht
</Link>
</div>
);
}
-1
View File
@@ -22,7 +22,6 @@ const MELDUNGEN = new Map<string, string>(
"oidc-aus": "Die Anmeldung über den Anbieter ist nicht eingerichtet.", "oidc-aus": "Die Anmeldung über den Anbieter ist nicht eingerichtet.",
"oidc-start": "Der Anbieter war nicht erreichbar. Bitte später erneut versuchen.", "oidc-start": "Der Anbieter war nicht erreichbar. Bitte später erneut versuchen.",
"oidc-rueckruf": "Die Anmeldung beim Anbieter ist fehlgeschlagen.", "oidc-rueckruf": "Die Anmeldung beim Anbieter ist fehlgeschlagen.",
keinrecht: "Für diesen Bereich fehlt dir die Berechtigung.",
}), }),
); );
+12 -5
View File
@@ -87,11 +87,18 @@ export async function sitzungErforderlich(recht?: Recht, ziel?: string): Promise
redirect(ziel ? `/anmelden?ziel=${encodeURIComponent(ziel)}` : "/anmelden"); redirect(ziel ? `/anmelden?ziel=${encodeURIComponent(ziel)}` : "/anmelden");
} }
if (recht && !darf(vorhanden.benutzer.rolle, recht)) { if (recht && !darf(vorhanden.benutzer.rolle, recht)) {
// Bei einer Seite ist ein geworfener Fehler eine 500er-Seite – fuer jemanden, /*
// der einfach ein altes Lesezeichen geoeffnet hat, die falsche Auskunft. * Bei einer Seite ist ein geworfener Fehler eine 500er-Seite – fuer jemanden,
// Deshalb hier eine Weiterleitung mit Erklaerung; in Server Actions bleibt * der einfach ein altes Lesezeichen geoeffnet hat, die falsche Auskunft.
// das Werfen richtig, siehe rechtErforderlich. * Deshalb eine Weiterleitung mit Erklaerung; in Server Actions bleibt das
redirect("/anmelden?fehler=keinrecht"); * Werfen richtig, siehe `rechtErforderlich`.
*
* Und zwar auf **`/kein-zugriff`**, nicht auf die Anmeldeseite: dort leitet
* ein Angemeldeter sofort auf die Uebersicht weiter, und die Erklaerung war
* damit unerreichbar. Der Kommentar hier versprach sie trotzdem — anderthalb
* Jahre haette niemand gemerkt, dass der Hinweis nie ankommt (#018).
*/
redirect(ziel ? `/kein-zugriff?bereich=${encodeURIComponent(ziel)}` : "/kein-zugriff");
} }
return vorhanden; return vorhanden;
} }
+41 -21
View File
@@ -1,6 +1,7 @@
import { expect, test, type Page } from "@playwright/test"; import { expect, test } from "@playwright/test";
import "dotenv/config"; import "dotenv/config";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { anmelden, SEED_VORLAGE } from "./helfer";
/** /**
* Das Archiv am gebauten Stand (#015). * Das Archiv am gebauten Stand (#015).
@@ -9,20 +10,18 @@ import { prisma } from "@/lib/db";
* Mandantengrenze hinein. Hier geht es um den Weg, den ein Mensch nimmt: Wort * Mandantengrenze hinein. Hier geht es um den Weg, den ein Mensch nimmt: Wort
* eintippen, Treffer sehen, **erkennen warum**, in den Lauf springen. Ein * eintippen, Treffer sehen, **erkennen warum**, in den Lauf springen. Ein
* Treffer ohne sichtbaren Grund ist fuer den Suchenden wertlos. * Treffer ohne sichtbaren Grund ist fuer den Suchenden wertlos.
*
* Voraussetzungen prueft `tests/e2e/global-setup.ts`, aufgeraeumt wird am Ende
* ueber die Datenbank: fuer einen Lauf kennt die Anwendung keinen Loeschweg, und
* das ist richtig so (#018).
*/ */
const STEMPEL = Date.now(); const STEMPEL = Date.now();
const NOTIZ = `Restposten ${STEMPEL} aus dem Lager`;
async function anmelden(seite: Page) { /** Fortlaufend, damit zwei Laeufe desselben Durchgangs sich nicht verwechseln. */
const email = process.env["SEED_ADMIN_EMAIL"]; let laufende = 0;
const passwort = process.env["SEED_ADMIN_PASSWORD"];
if (!email || !passwort) throw new Error("SEED_ADMIN_EMAIL und SEED_ADMIN_PASSWORD fehlen."); type Testlauf = { readonly id: string; readonly titel: string; readonly suchwort: string };
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"]')]);
}
/** /**
* Ein archivierter Lauf mit einer Notiz, die es sonst nirgends gibt. * Ein archivierter Lauf mit einer Notiz, die es sonst nirgends gibt.
@@ -31,19 +30,31 @@ async function anmelden(seite: Page) {
* geprueft werden, nicht der Weg dorthin — den prueft `tests/e2e/laeufe.spec.ts` * geprueft werden, nicht der Weg dorthin — den prueft `tests/e2e/laeufe.spec.ts`
* vom Anlegen bis zum Archivieren. Die Zeitstempel gehoeren trotzdem dazu, drei * vom Anlegen bis zum Archivieren. Die Zeitstempel gehoeren trotzdem dazu, drei
* CHECK-Bedingungen in der Datenbank verlangen sie. * CHECK-Bedingungen in der Datenbank verlangen sie.
*
* Vorlage **am Namen**, Kunde ueber `istEigeneOrganisation`: ein
* `findFirstOrThrow` ohne Bedingung nahm die Version, die die Sortierung gerade
* nach vorn stellte, und im Modus DIENSTLEISTUNG einen beliebigen Demo-Kunden.
* Dass es `SEED_VORLAGE` veroeffentlicht gibt, prueft der Global-Setup.
*/ */
async function archivierterLauf(): Promise<{ id: string; titel: string }> { async function archivierterLauf(): Promise<Testlauf> {
laufende += 1;
const marke = `${STEMPEL}-${laufende}`;
const suchwort = `Restposten ${marke}`;
const version = await prisma.vorlagenVersion.findFirstOrThrow({ const version = await prisma.vorlagenVersion.findFirstOrThrow({
where: { status: "VEROEFFENTLICHT" }, where: { status: "VEROEFFENTLICHT", vorlage: { name: SEED_VORLAGE } },
orderBy: { veroeffentlichtAm: "desc" }, orderBy: { version: "desc" },
select: { id: true, abschnitte: { select: { schritte: { select: { id: true } } } } }, select: { id: true, abschnitte: { select: { schritte: { select: { id: true } } } } },
}); });
const kunde = await prisma.kunde.findFirstOrThrow({ select: { id: true } }); const kunde = await prisma.kunde.findFirstOrThrow({
where: { istEigeneOrganisation: true },
select: { id: true },
});
const schrittId = version.abschnitte.flatMap((abschnitt) => abschnitt.schritte)[0]?.id; const schrittId = version.abschnitte.flatMap((abschnitt) => abschnitt.schritte)[0]?.id;
if (!schrittId) throw new Error("Die Vorlage hat keinen Schritt."); if (!schrittId) throw new Error(`Die Vorlage „${SEED_VORLAGE}“ hat keinen Schritt.`);
const jetzt = new Date(); const jetzt = new Date();
const titel = `ZZ E2E Archiv ${STEMPEL}`; const titel = `ZZ E2E Archiv ${marke}`;
const lauf = await prisma.lauf.create({ const lauf = await prisma.lauf.create({
data: { data: {
vorlagenVersionId: version.id, vorlagenVersionId: version.id,
@@ -60,10 +71,10 @@ async function archivierterLauf(): Promise<{ id: string; titel: string }> {
laufId: lauf.id, laufId: lauf.id,
vorlagenSchrittId: schrittId, vorlagenSchrittId: schrittId,
zustand: "NICHT_ZUTREFFEND", zustand: "NICHT_ZUTREFFEND",
notiz: NOTIZ, notiz: `${suchwort} aus dem Lager`,
}, },
}); });
return { id: lauf.id, titel }; return { id: lauf.id, titel, suchwort };
} }
test.describe("Archiv", () => { test.describe("Archiv", () => {
@@ -77,7 +88,7 @@ test.describe("Archiv", () => {
} }
await page.getByRole("link", { name: "Archiv" }).first().click(); await page.getByRole("link", { name: "Archiv" }).first().click();
await page.waitForURL("**/archiv"); await page.waitForURL("**/archiv");
await page.getByLabel("Suchbegriff").fill(`Restposten ${STEMPEL}`); await page.getByLabel("Suchbegriff").fill(lauf.suchwort);
await page.getByRole("button", { name: "Suchen" }).click(); await page.getByRole("button", { name: "Suchen" }).click();
const treffer = page.getByRole("listitem").filter({ hasText: lauf.titel }); const treffer = page.getByRole("listitem").filter({ hasText: lauf.titel });
@@ -102,8 +113,16 @@ test.describe("Archiv", () => {
test("trägt am Handy ohne horizontales Scrollen", async ({ page, isMobile }) => { test("trägt am Handy ohne horizontales Scrollen", async ({ page, isMobile }) => {
test.skip(!isMobile, "Nur im Projekt „handy“."); test.skip(!isMobile, "Nur im Projekt „handy“.");
/*
* Der Ablauf bringt seinen Treffer mit, statt auf einen zu hoffen: gegen
* eine frische Datenbank fand „Restposten“ nichts, und gemessen wurde dann
* die Breite einer Seite ohne Treffer (#018).
*/
const lauf = await archivierterLauf();
await anmelden(page); await anmelden(page);
await page.goto("/archiv?suche=Restposten"); await page.goto(`/archiv?suche=${encodeURIComponent(lauf.suchwort)}`);
await expect(page.getByRole("listitem").filter({ hasText: lauf.titel })).toHaveCount(1);
const masse = await page.evaluate(() => ({ const masse = await page.evaluate(() => ({
scroll: document.documentElement.scrollWidth, scroll: document.documentElement.scrollWidth,
sichtbar: document.documentElement.clientWidth, sichtbar: document.documentElement.clientWidth,
@@ -114,6 +133,7 @@ test.describe("Archiv", () => {
test.afterAll(async () => { test.afterAll(async () => {
const laeufe = await prisma.lauf.findMany({ const laeufe = await prisma.lauf.findMany({
// Der Praefix gehoert dieser Datei allein – geraeumt wird nur, was sie anlegt.
where: { titel: { startsWith: "ZZ E2E Archiv" } }, where: { titel: { startsWith: "ZZ E2E Archiv" } },
select: { id: true }, select: { id: true },
}); });
+10 -28
View File
@@ -1,6 +1,6 @@
import { expect, test, type Page } from "@playwright/test"; import { expect, test } from "@playwright/test";
import "dotenv/config"; import "dotenv/config";
import { prisma } from "@/lib/db"; import { anmelden, SEED_TAG } from "./helfer";
/** /**
* Der Etikettendruck am gebauten Stand (#014). * Der Etikettendruck am gebauten Stand (#014).
@@ -14,34 +14,21 @@ import { prisma } from "@/lib/db";
* Die Aufloesung: 1 mm sind 96/25,4 px, ein `deviceScaleFactor` von 300/96 = * Die Aufloesung: 1 mm sind 96/25,4 px, ein `deviceScaleFactor` von 300/96 =
* 3,125 macht daraus 300 dpi. 62 mm werden so zu 62 · 96/25,4 · 3,125 ≈ 732 * 3,125 macht daraus 300 dpi. 62 mm werden so zu 62 · 96/25,4 · 3,125 ≈ 732
* Geraetepixeln — genau die Aufloesung, mit der ein Etikettendrucker arbeitet. * Geraetepixeln — genau die Aufloesung, mit der ein Etikettendrucker arbeitet.
*
* Gedruckt wird `SEED_TAG` und nicht irgendein Code aus der Datenbank: dass es
* ihn gibt, prueft `tests/e2e/global-setup.ts` vor dem ersten Ablauf. Ein
* `findFirst` haette gegen eine frische Installation „kein Geraet“ gemeldet und
* gegen eine benutzte einen beliebigen Aufkleber gedruckt (#018).
*/ */
const DPI = 300; const DPI = 300;
const PX_JE_MM = 96 / 25.4; const PX_JE_MM = 96 / 25.4;
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"]')]);
}
/** Irgendein Tag-Code, den es in dieser Datenbank wirklich gibt. */
async function einTagCode(): Promise<string> {
const geraet = await prisma.geraet.findFirst({ select: { tagCode: true } });
if (!geraet) throw new Error("Kein Gerät in der Datenbank — erst pnpm db:seed.");
return geraet.tagCode;
}
test.describe("Etikettendruck", () => { test.describe("Etikettendruck", () => {
test("druckt ein Einzeletikett auf 62 × 29 mm", async ({ page }) => { test("druckt ein Einzeletikett auf 62 × 29 mm", async ({ page }) => {
test.skip(test.info().project.name !== "desktop", "Das Seitenformat hängt nicht am Gerät."); test.skip(test.info().project.name !== "desktop", "Das Seitenformat hängt nicht am Gerät.");
await anmelden(page); await anmelden(page);
const code = await einTagCode(); await page.goto(`/drucken/etiketten?layout=etikett&codes=${SEED_TAG}`);
await page.goto(`/drucken/etiketten?layout=etikett&codes=${code}`);
/* /*
* `page.pdf()` gilt in Chromium und beachtet `@page`. Gemessen wird die * `page.pdf()` gilt in Chromium und beachtet `@page`. Gemessen wird die
@@ -69,7 +56,6 @@ test.describe("Etikettendruck", () => {
test("ein bei 300 dpi gerastertes Etikett lässt sich zurücklesen", async ({ browser }) => { test("ein bei 300 dpi gerastertes Etikett lässt sich zurücklesen", async ({ browser }) => {
test.skip(test.info().project.name !== "desktop", "Der Decoder hängt nicht am Gerät."); test.skip(test.info().project.name !== "desktop", "Der Decoder hängt nicht am Gerät.");
const code = await einTagCode();
/* /*
* Eigener Kontext: der `deviceScaleFactor` ist die Aufloesung, und die * Eigener Kontext: der `deviceScaleFactor` ist die Aufloesung, und die
@@ -84,7 +70,7 @@ test.describe("Etikettendruck", () => {
try { try {
const tab = await kontext.newPage(); const tab = await kontext.newPage();
await anmelden(tab); await anmelden(tab);
await tab.goto(`/drucken/etiketten?layout=etikett&codes=${code}`); await tab.goto(`/drucken/etiketten?layout=etikett&codes=${SEED_TAG}`);
const etikett = tab.locator("[data-etikett]").first(); const etikett = tab.locator("[data-etikett]").first();
await expect(etikett).toBeVisible(); await expect(etikett).toBeVisible();
const bild = await etikett.screenshot(); const bild = await etikett.screenshot();
@@ -100,7 +86,7 @@ test.describe("Etikettendruck", () => {
* dazulegen koennte. * dazulegen koennte.
*/ */
const adresse = new URL(gelesen[0] ?? ""); const adresse = new URL(gelesen[0] ?? "");
expect(adresse.pathname).toBe(`/t/${code}`); expect(adresse.pathname).toBe(`/t/${SEED_TAG}`);
expect(adresse.search).toBe(""); expect(adresse.search).toBe("");
expect(adresse.hash).toBe(""); expect(adresse.hash).toBe("");
} finally { } finally {
@@ -157,7 +143,3 @@ function seitenmasseAusPdf(pdf: Buffer): { breite: number; hoehe: number } {
hoehe: Number(treffer[4]) - Number(treffer[2]), hoehe: Number(treffer[4]) - Number(treffer[2]),
}; };
} }
test.afterAll(async () => {
await prisma.$disconnect();
});
+9 -12
View File
@@ -1,5 +1,6 @@
import { expect, test, type Page } from "@playwright/test"; import { expect, test, type Page } from "@playwright/test";
import "dotenv/config"; import "dotenv/config";
import { anmelden, kundeWaehlen } from "./helfer";
/** /**
* Die Geraeteliste durch die Oberflaeche (#011). * Die Geraeteliste durch die Oberflaeche (#011).
@@ -11,8 +12,10 @@ import "dotenv/config";
* einlesen, loeschen — und anschliessend, ob es wirklich in der Datenbank steht: * einlesen, loeschen — und anschliessend, ob es wirklich in der Datenbank steht:
* durch Neuladen. * durch Neuladen.
* *
* Voraussetzung: ein **gebauter** Stand laeuft (`pnpm build && pnpm start`) und * Voraussetzungen — ein gebauter Stand antwortet, der Seed-Bestand steht und der
* `SEED_ADMIN_EMAIL`/`SEED_ADMIN_PASSWORD` sind gesetzt. * Server liest dieselbe Datenbank wie dieser Lauf — prueft
* `tests/e2e/global-setup.ts` vor dem ersten Ablauf (#018). Eigene Daten legt
* dieser Ablauf selbst an; auf vorhandene baut er nicht.
* *
* Der Ablauf raeumt hinter sich auf: an den angelegten Geraeten haengt kein Lauf, * Der Ablauf raeumt hinter sich auf: an den angelegten Geraeten haengt kein Lauf,
* sie lassen sich also loeschen. Ihre Tag-Codes bleiben vergeben, und genau das * sie lassen sich also loeschen. Ihre Tag-Codes bleiben vergeben, und genau das
@@ -21,16 +24,6 @@ import "dotenv/config";
const MARKE = `ZZ-E2E-${Date.now()}`; const MARKE = `ZZ-E2E-${Date.now()}`;
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"]')]);
}
/** Der Abschnitt „Gerät anlegen“ – eindeutig ueber seinen eigenen Knopf. */ /** Der Abschnitt „Gerät anlegen“ – eindeutig ueber seinen eigenen Knopf. */
function anlegenFormular(seite: Page) { function anlegenFormular(seite: Page) {
return seite.locator("section", { has: seite.getByRole("button", { name: "Gerät anlegen" }) }); return seite.locator("section", { has: seite.getByRole("button", { name: "Gerät anlegen" }) });
@@ -70,6 +63,7 @@ test.describe("Geräte", () => {
await formular.getByLabel("Hostname").fill(name); await formular.getByLabel("Hostname").fill(name);
await formular.getByLabel("Seriennummer").fill(`${MARKE}-SN`); await formular.getByLabel("Seriennummer").fill(`${MARKE}-SN`);
await formular.getByLabel("Typ").selectOption("NOTEBOOK"); await formular.getByLabel("Typ").selectOption("NOTEBOOK");
await kundeWaehlen(formular);
await formular.getByRole("button", { name: "Gerät anlegen" }).click(); await formular.getByRole("button", { name: "Gerät anlegen" }).click();
// Der Tag-Code steht in der Meldung – er kommt gleich auf den Aufkleber. // Der Tag-Code steht in der Meldung – er kommt gleich auf den Aufkleber.
@@ -109,6 +103,7 @@ test.describe("Geräte", () => {
const formular = anlegenFormular(page); const formular = anlegenFormular(page);
await formular.getByLabel("Hostname").fill(name); await formular.getByLabel("Hostname").fill(name);
await kundeWaehlen(formular);
await formular.getByRole("button", { name: "Gerät anlegen" }).click(); await formular.getByRole("button", { name: "Gerät anlegen" }).click();
await expect(formular.getByRole("status")).toContainText("wurde angelegt"); await expect(formular.getByRole("status")).toContainText("wurde angelegt");
@@ -150,6 +145,7 @@ test.describe("Geräte", () => {
`${MARKE}-CSV-BOESE;;SN-BOESE;INV-2;Tablet;`, `${MARKE}-CSV-BOESE;;SN-BOESE;INV-2;Tablet;`,
].join("\r\n"), ].join("\r\n"),
); );
await kundeWaehlen(bereich);
await bereich.getByRole("button", { name: "Einlesen" }).click(); await bereich.getByRole("button", { name: "Einlesen" }).click();
await expect(bereich.getByRole("status")).toContainText("1 von 2 Zeilen angelegt, 1 Zeile abgewiesen."); await expect(bereich.getByRole("status")).toContainText("1 von 2 Zeilen angelegt, 1 Zeile abgewiesen.");
@@ -175,6 +171,7 @@ test.describe("Geräte", () => {
const formular = anlegenFormular(page); const formular = anlegenFormular(page);
await formular.getByLabel("Bezeichnung").fill(name); await formular.getByLabel("Bezeichnung").fill(name);
await formular.getByLabel("Typ").selectOption("RAUM"); await formular.getByLabel("Typ").selectOption("RAUM");
await kundeWaehlen(formular);
await formular.getByRole("button", { name: "Gerät anlegen" }).click(); await formular.getByRole("button", { name: "Gerät anlegen" }).click();
await expect(formular.getByRole("status")).toContainText("wurde angelegt"); await expect(formular.getByRole("status")).toContainText("wurde angelegt");
+42 -20
View File
@@ -1,6 +1,7 @@
import { expect, test, type Page } from "@playwright/test"; import { expect, test } from "@playwright/test";
import "dotenv/config"; import "dotenv/config";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { anmelden, SEED_TAG } from "./helfer";
/** /**
* Der Weg vom Aufkleber zur Arbeit (#014). * Der Weg vom Aufkleber zur Arbeit (#014).
@@ -9,19 +10,17 @@ import { prisma } from "@/lib/db";
* tun ist, loslegen. Geprueft wird er am **gebauten** Stand, weil genau hier die * tun ist, loslegen. Geprueft wird er am **gebauten** Stand, weil genau hier die
* Teile zusammenkommen, die einzeln schon funktionierten — die Weiche unter * Teile zusammenkommen, die einzeln schon funktionierten — die Weiche unter
* `/t/<code>`, das Registrieren, die Geraeteseite und der Lauf. * `/t/<code>`, das Registrieren, die Geraeteseite und der Lauf.
*
* Voraussetzungen prueft `tests/e2e/global-setup.ts`. Das Beispielgeraet wird
* ueber `SEED_TAG` gesucht und nicht ueber den Hostnamen „PC-001“: im Modus
* DIENSTLEISTUNG traegt der Name das Kundenkuerzel, und in einer benutzten
* Datenbank gibt es „PC-001“ mehr als einmal (#018).
*/ */
const STEMPEL = Date.now(); const STEMPEL = Date.now();
async function anmelden(seite: Page) { /** Praefix der Geraete, die dieser Ablauf anlegt – und nur dieser. */
const email = process.env["SEED_ADMIN_EMAIL"]; const HOSTNAME_PRAEFIX = "ZZ-E2E-TAG-";
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"]')]);
}
/** /**
* Eine schlichte Vorlage **ohne** Pflichtangaben. * Eine schlichte Vorlage **ohne** Pflichtangaben.
@@ -59,7 +58,18 @@ async function tagAnlegen(): Promise<string> {
where: { rolle: "ADMIN" }, where: { rolle: "ADMIN" },
select: { id: true, rolle: true }, select: { id: true, rolle: true },
}); });
const stapel = await tagsErzeugen(1, null, { id: admin.id, rolle: admin.rolle }); /*
* Der Kunde muss mit: im Modus DIENSTLEISTUNG verlangt `kundeErmitteln` eine
* Wahl und wirft sonst „Im Modus Dienstleistung muss ein Kunde gewählt
* werden“. Genommen wird der Kunde des Beispielgeraets — an ihm haengt auch
* die Geraeteseite, die dieser Ablauf danach oeffnet. Im Modus INTERN wird die
* Kennung verworfen, die Angabe schadet dort also nicht (#008).
*/
const beispiel = await prisma.geraet.findUniqueOrThrow({
where: { tagCode: SEED_TAG },
select: { kundeId: true },
});
const stapel = await tagsErzeugen(1, beispiel.kundeId, { id: admin.id, rolle: admin.rolle });
const code = stapel.tagCodes[0]; const code = stapel.tagCodes[0];
if (!code) throw new Error("Der Stapel kam leer zurück."); if (!code) throw new Error("Der Stapel kam leer zurück.");
eigeneTags.push(code); eigeneTags.push(code);
@@ -82,13 +92,13 @@ test.describe("Vom Aufkleber zur Arbeit", () => {
* muss er auf **der Geräteseite** stehen und nicht auf der Übersicht — sonst * muss er auf **der Geräteseite** stehen und nicht auf der Übersicht — sonst
* sucht er das Gerät noch einmal, das er gerade in der Hand hält. * sucht er das Gerät noch einmal, das er gerade in der Hand hält.
*/ */
const geraet = await prisma.geraet.findFirstOrThrow({ const geraet = await prisma.geraet.findUniqueOrThrow({
where: { hostname: "PC-001" }, where: { tagCode: SEED_TAG },
select: { id: true, tagCode: true }, select: { id: true, hostname: true },
}); });
await page.context().clearCookies(); await page.context().clearCookies();
await page.goto(`/t/${geraet.tagCode}`); await page.goto(`/t/${SEED_TAG}`);
await page.waitForURL(/\/anmelden\?ziel=/); await page.waitForURL(/\/anmelden\?ziel=/);
const email = process.env["SEED_ADMIN_EMAIL"]; const email = process.env["SEED_ADMIN_EMAIL"];
@@ -99,7 +109,8 @@ test.describe("Vom Aufkleber zur Arbeit", () => {
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await page.waitForURL(`**/geraete/${geraet.id}`); await page.waitForURL(`**/geraete/${geraet.id}`);
await expect(page.getByRole("heading", { name: "PC-001" })).toBeVisible(); // Der Name kommt aus der Datenbank: er heisst nicht in jedem Modus „PC-001“.
await expect(page.getByRole("heading", { name: geraet.hostname ?? SEED_TAG })).toBeVisible();
}); });
test("registriert einen vorgedruckten Tag und arbeitet daran weiter", async ({ page }) => { test("registriert einen vorgedruckten Tag und arbeitet daran weiter", async ({ page }) => {
@@ -111,7 +122,7 @@ test.describe("Vom Aufkleber zur Arbeit", () => {
await page.goto(`/t/${code.toLowerCase()}`); await page.goto(`/t/${code.toLowerCase()}`);
await expect(page.getByRole("heading", { name: "Gerät registrieren" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Gerät registrieren" })).toBeVisible();
const hostname = `ZZ-E2E-${STEMPEL}`; const hostname = `${HOSTNAME_PRAEFIX}${STEMPEL}`;
await page.getByLabel("Hostname").fill(hostname); await page.getByLabel("Hostname").fill(hostname);
await page.getByLabel("Typ").selectOption("NOTEBOOK"); await page.getByLabel("Typ").selectOption("NOTEBOOK");
await page.getByRole("button", { name: "Gerät registrieren" }).click(); await page.getByRole("button", { name: "Gerät registrieren" }).click();
@@ -156,9 +167,15 @@ test.describe("Vom Aufkleber zur Arbeit", () => {
test("trägt am Handy ohne horizontales Scrollen", async ({ page, isMobile }) => { test("trägt am Handy ohne horizontales Scrollen", async ({ page, isMobile }) => {
test.skip(!isMobile, "Nur im Projekt „handy“."); test.skip(!isMobile, "Nur im Projekt „handy“.");
/*
* Gesucht wird ueber den Tag-Code und nicht ueber „NB-001“: der Name war eine
* Annahme ueber den Beispielbestand, und in einer benutzten Datenbank fuehrt
* er auf mehr als ein Geraet. Der Code ist eindeutig — die Liste zeigt danach
* genau eine Zeile, und der Weg ueber die Liste bleibt der des Technikers.
*/
await anmelden(page); await anmelden(page);
await page.goto("/geraete"); await page.goto(`/geraete?suche=${SEED_TAG}`);
await page.getByRole("link", { name: "NB-001" }).first().click(); await page.locator("ul > li").filter({ hasText: SEED_TAG }).getByRole("link").first().click();
await page.waitForURL(/\/geraete\/[^/]+$/); await page.waitForURL(/\/geraete\/[^/]+$/);
const masse = await page.evaluate(() => ({ const masse = await page.evaluate(() => ({
@@ -186,7 +203,12 @@ test.afterAll(async () => {
* Entwicklungsdatenbank ist das derselbe Bestand. * Entwicklungsdatenbank ist das derselbe Bestand.
*/ */
const geraete = await prisma.geraet.findMany({ const geraete = await prisma.geraet.findMany({
where: { OR: [{ hostname: { startsWith: "ZZ-E2E-" } }, { tagCode: { in: eigeneTags } }] }, /*
* Der Praefix gehoert dieser Datei allein. Vorher stand hier `ZZ-E2E-`, und
* damit raeumte dieser Nachlauf auch die Geraete aus
* `tests/e2e/geraete.spec.ts` weg — fremde Daten, wenn auch Testdaten (#018).
*/
where: { OR: [{ hostname: { startsWith: HOSTNAME_PRAEFIX } }, { tagCode: { in: eigeneTags } }] },
select: { id: true }, select: { id: true },
}); });
const kennungen = geraete.map((geraet) => geraet.id); const kennungen = geraete.map((geraet) => geraet.id);
+103
View File
@@ -0,0 +1,103 @@
import { chromium } from "@playwright/test";
import "dotenv/config";
import { prisma } from "@/lib/db";
import { anmelden, SEED_GERAET, SEED_TAG, SEED_VORLAGE } from "./helfer";
/**
* Was vor dem ersten Ablauf stimmen muss (#018).
*
* Bisher standen die Voraussetzungen als Satz im Kopfkommentar jeder Datei
* („Voraussetzung: ein gebauter Stand laeuft und `pnpm beispiel:vorlage` hat
* …“). Ein Kommentar prueft nichts. Stimmte etwas davon nicht, fiel das erst
* mitten im Lauf auf, und die Meldung sprach von einem Zeitablauf oder von null
* Treffern — nie von der Ursache.
*
* Hier wird es geprueft, einmal, bevor irgendein Ablauf startet. Jede Meldung
* sagt, **was** fehlt und **was zu tun ist**.
*/
const APP_URL = process.env["E2E_APP_URL"] ?? "http://127.0.0.1:3000";
function fehlen(was: string, abhilfe: string): never {
throw new Error(`Ende-zu-Ende-Tests: ${was}\n → ${abhilfe}`);
}
export default async function vorbereiten(): Promise<void> {
if (!process.env["SEED_ADMIN_EMAIL"] || !process.env["SEED_ADMIN_PASSWORD"]) {
fehlen(
"SEED_ADMIN_EMAIL oder SEED_ADMIN_PASSWORD fehlt.",
"Beide stehen in der .env und müssen zu dem Administrator passen, den der Seed angelegt hat.",
);
}
// 1. Laeuft ueberhaupt etwas an dieser Adresse?
const antwort = await fetch(`${APP_URL}/api/gesundheit`, { signal: AbortSignal.timeout(5000) }).catch(
() => undefined,
);
if (!antwort?.ok) {
fehlen(
`Unter ${APP_URL} antwortet keine Anwendung.`,
"Erst bauen und starten: pnpm build && PORT=3300 APP_URL=http://127.0.0.1:3300 pnpm start — " +
"und die Adresse über E2E_APP_URL mitgeben. Gegen `pnpm dev` laufen die Abläufe nicht " +
"(siehe CLAUDE.md, „Oberfläche prüfen“).",
);
}
// 2. Steht der Seed-Bestand, mit dem die Ablaeufe arbeiten?
const vorlage = await prisma.vorlage.findFirst({
where: { name: SEED_VORLAGE },
select: { id: true, versionen: { where: { status: "VEROEFFENTLICHT" }, select: { id: true } } },
});
if (!vorlage || vorlage.versionen.length === 0) {
fehlen(
`Die Vorlage „${SEED_VORLAGE}“ fehlt oder ist nicht veröffentlicht.`,
"pnpm db:seed laufen lassen.",
);
}
const geraet = await prisma.geraet.findUnique({ where: { tagCode: SEED_TAG }, select: { id: true } });
if (!geraet) {
fehlen(`Das Beispielgerät ${SEED_GERAET} (${SEED_TAG}) fehlt.`, "pnpm db:seed laufen lassen.");
}
/*
* 2b. In welchem Betriebsmodus laeuft die Installation?
*
* Ein Ablauf soll das nicht selbst aus der Datenbank holen muessen — und zwei
* Quellen fuer dieselbe Auskunft liefen frueher oder spaeter auseinander. Der
* Modus steht danach in `E2E_BETRIEBSMODUS`, und ein Ablauf, der nur in einem
* der beiden Sinn ergibt, ueberspringt sich selbst mit einem Grund.
*/
const einstellung = await prisma.einstellung.findUnique({
where: { id: "global" },
select: { modus: true },
});
process.env["E2E_BETRIEBSMODUS"] = einstellung?.modus ?? "INTERN";
/*
* 3. Liest der Server **dieselbe** Datenbank?
*
* Der Testprozess schreibt über DATABASE_URL aus der .env, der Server bekommt
* seine eigene mit. Zeigen beide woandershin, legt ein Ablauf seine Daten in
* die eine und sucht sie in der anderen. Geprüft wird an einer Vorlage, die
* hier entsteht und gleich wieder verschwindet.
*/
const probe = `ZZ Datenbankprüfung ${Date.now()}`;
const angelegt = await prisma.vorlage.create({ data: { name: probe, kategorie: "ZZ Test" } });
const browser = await chromium.launch();
try {
const tab = await browser.newPage({ baseURL: APP_URL });
await anmelden(tab);
await tab.goto(`/vorlagen?suche=${encodeURIComponent(probe)}`);
if ((await tab.getByRole("link", { name: probe }).count()) === 0) {
fehlen(
"Der Server unter Test liest eine andere Datenbank als dieser Lauf.",
"DATABASE_URL des Testprozesses (aus der .env) und die des Servers müssen auf dieselbe " +
"Datenbank zeigen — sonst schreibt der Ablauf in die eine und sucht in der anderen.",
);
}
} finally {
await browser.close();
await prisma.vorlage.delete({ where: { id: angelegt.id } });
await prisma.$disconnect();
}
}
+332
View File
@@ -0,0 +1,332 @@
import { expect, test, type Locator, type Page } from "@playwright/test";
import "dotenv/config";
import { prisma } from "@/lib/db";
import { anmelden, geraetName, SEED_VORLAGE, variablenFuellen, vorlageWaehlen } from "./helfer";
/**
* Der Hauptablauf des ganzen Auftrags, in einem Zug (#018).
*
* Anmelden → „PC Neuinstallation“ für das Beispielgerät starten → abarbeiten →
* einen Schritt mit Begründung überspringen → abschließen → archivieren → im
* Archiv über ein Wort aus der Begründung wiederfinden → und danach ändert sich
* nichts mehr.
*
* Zwei Dinge unterscheiden diesen Ablauf von einem Klickprotokoll:
*
* 1. **Geprüft wird gegen die Datenbank.** Nach jeder Änderung wird neu geladen
* und in der Datenbank nachgesehen. Ein Klick, der nur im Browser wirkt, sieht
* auf einem Screenshot genauso aus wie einer, der gespeichert wurde — und
* genau dieser Unterschied ist der Grund, warum es diese Datei gibt
* (CLAUDE.md, „Oberfläche prüfen“).
* 2. **Die Absage wird erzwungen.** „Nicht zutreffend“ ohne Begründung sperrt die
* Oberfläche nur über einen deaktivierten Knopf. Ein deaktivierter Knopf ist
* keine Zusage: dieser Ablauf entfernt ihn und schickt trotzdem ab, damit die
* Absage vom **Server** bewiesen ist und nicht von einem HTML-Attribut.
*
* Der Ablauf legt an, was er braucht (einen eigenen Lauf mit `ZZ E2E `-Titel) und
* räumt ihn hinterher weg. Vorausgesetzt wird nur der Seed-Bestand, den
* `global-setup.ts` prüft.
*/
const STEMPEL = Date.now();
const TITEL = `ZZ E2E Hauptablauf ${STEMPEL}`;
/** Der Schritt, der übersprungen wird — Pflicht, damit auch der Abschluss etwas davon hat. */
const UEBERSPRUNGEN = "Secure Boot und TPM 2.0 aktiv";
/** Der Schritt, in dem ein Wert erfasst wird. Typ TEXT in der Seed-Vorlage. */
const WERTSCHRITT = "Sondersoftware und Lizenzen des Benutzers notiert";
const WERT = `ZZ E2E Adobe Acrobat ${STEMPEL}`;
/**
* Das Wertfeld eines Schritts – ohne Annahme über das Element.
*
* `TEXT` mit `mehrzeilig` ist ein `textarea`, alles andere ein `input`. Ein
* `input[name="wert"]` lief deshalb in den Zeitablauf, und die Meldung sprach von
* einem fehlenden Element statt von der falschen Annahme.
*/
const WERTFELD = '[name="wert"]';
/**
* Ein Wort, das es in der ganzen Datenbank nur einmal gibt.
*
* Über dieses Wort wird der Lauf später im Archiv wiedergefunden. Ohne Stempel
* fände die Suche auch die Notizen früherer Durchläufe, und der Treffer sagte
* nichts mehr über diesen hier.
*/
const NOTIZWORT = `Fabrikneu${STEMPEL}`;
const NOTIZ = `${NOTIZWORT}: das Gerät kam versiegelt aus dem Karton, TPM war ab Werk aktiv.`;
/**
* Legt den Lauf über die Oberfläche an und gibt seine Adresse zurück.
*
* Bewusst nicht über Prisma: das Anlegen **ist** Teil des Hauptablaufs. Die
* Vorlage wird am Namen gewählt (`vorlageWaehlen`), das Gerät am angezeigten
* Namen — in beiden Betriebsmodi, denn im Modus DIENSTLEISTUNG heißt das
* Beispielgerät anders und der Dialog fragt zusätzlich nach dem Kunden.
*/
async function laufStarten(seite: Page): Promise<string> {
await seite.goto("/laeufe/starten");
await vorlageWaehlen(seite, SEED_VORLAGE);
const gesucht = geraetName();
const geraete = await seite
.locator('select[name="geraetId"] option')
.evaluateAll((liste) =>
liste
.filter((eintrag): eintrag is HTMLOptionElement => eintrag instanceof HTMLOptionElement)
.map((eintrag) => ({ wert: eintrag.value, text: eintrag.textContent ?? "" })),
);
const treffer = geraete.find((eintrag) => eintrag.text.includes(gesucht));
if (!treffer) {
throw new Error(
`Das Gerät „${gesucht}“ steht nicht zur Auswahl. Zur Wahl stehen: ` +
`${geraete.map((eintrag) => eintrag.text).join(", ")}`,
);
}
await seite.selectOption('select[name="geraetId"]', treffer.wert);
/*
* Im Modus DIENSTLEISTUNG verlangt der Dialog einen Kunden, und die Datenbank
* verlangt, dass es **der Kunde des Geräts** ist: ein zusammengesetzter
* Fremdschlüssel lässt keinen Lauf mit fremdem Gerät zu. Ein „nimm den
* ersten“ wäre also nicht nur ungenau, sondern im Zweifel rot.
*/
const kundenWahl = seite.locator('select[name="kundeId"]');
if ((await kundenWahl.count()) > 0) {
const geraet = await prisma.geraet.findFirst({
where: { OR: [{ hostname: gesucht }, { bezeichnung: gesucht }] },
select: { kunde: { select: { name: true } } },
});
if (!geraet) throw new Error(`Das Gerät „${gesucht}“ steht nicht in der Datenbank.`);
await kundenWahl.selectOption({ label: geraet.kunde.name });
}
const weitere = seite.locator("details").filter({ hasText: "Titel, Ticket" });
await weitere.locator("summary").click();
await weitere.locator('input[name="titel"]').fill(TITEL);
await variablenFuellen(seite);
await seite.getByRole("button", { name: "Lauf anlegen" }).click();
await seite.waitForURL(/\/laeufe\/(?!starten)[^/]+$/, { timeout: 60_000 });
return new URL(seite.url()).pathname;
}
/** Die Abschnitte der Abarbeitungsansicht – am aufklappbaren Kopf erkannt. */
function abschnitteVon(seite: Page): Locator {
return seite.locator("section").filter({ has: seite.locator("button[aria-expanded]") });
}
test.describe("Hauptablauf", () => {
test("starten, abarbeiten, abschließen, archivieren, wiederfinden", async ({ page }) => {
/*
* Die Seed-Vorlage hat über dreißig Schritte, und jeder speichert einzeln —
* so arbeitet ein Techniker, und so soll es geprüft werden. Das dauert; die
* Voreinstellung von 30 Sekunden reicht dafür nicht.
*/
test.setTimeout(420_000);
await anmelden(page);
const pfad = await laufStarten(page);
const laufId = pfad.split("/").pop() as string;
// Ein neuer Lauf ist **offen**: das ist die Zuweisung, nicht der Start.
expect((await prisma.lauf.findUniqueOrThrow({ where: { id: laufId } })).status).toBe("OFFEN");
await page.getByRole("button", { name: "Lauf beginnen" }).click();
const abschliessen = page.getByRole("button", { name: "Lauf abschließen" });
await expect(abschliessen).toBeVisible();
await expect(abschliessen).toBeDisabled();
// ---- Ein Wert in einem Textschritt --------------------------------------
const wertzeile = page.getByRole("listitem").filter({ hasText: WERTSCHRITT });
await wertzeile.locator(WERTFELD).fill(WERT);
await wertzeile.getByRole("button", { name: "Wert speichern" }).click();
/*
* Auf die **Datenbank** warten, nicht auf den Bildschirm: das Feld trägt den
* Wert schon, bevor irgendetwas gespeichert ist — es ist ein kontrolliertes
* Eingabefeld. Und ein `reload` mitten in der Aktion bricht sie ab, also erst
* warten, dann neu laden.
*/
await expect
.poll(
async () =>
(
await prisma.schrittergebnis.findFirst({
where: { laufId, vorlagenSchritt: { titel: WERTSCHRITT } },
select: { wert: true },
})
)?.wert,
{ timeout: 30_000, message: `Der Wert des Schritts „${WERTSCHRITT}“ steht nicht in der Datenbank.` },
)
.toBe(WERT);
await page.reload();
await expect(wertzeile.locator(WERTFELD)).toHaveValue(WERT);
// ---- „Nicht zutreffend“ verlangt eine Begründung ------------------------
const zeile = page.getByRole("listitem").filter({ hasText: UEBERSPRUNGEN });
const pille = zeile.getByRole("button", { name: "nicht zutreffend" });
await pille.click();
const mitBegruendung = zeile.getByRole("button", { name: "Mit Begründung speichern" });
await expect(mitBegruendung).toBeVisible();
await expect(mitBegruendung).toBeDisabled();
/*
* Und jetzt der eigentliche Nachweis: der gesperrte Knopf wird entsperrt und
* das Pflichtfeld entschärft, dann wird abgeschickt. Ein deaktivierter Knopf
* ist Bequemlichkeit — die Zusage („im Archiv steht kein übersprungener
* Schritt ohne Grund“) muss der Server halten, und nur so ist bewiesen, dass
* er es tut.
*/
await zeile.locator('textarea[name="notiz"]').evaluate((feld) => feld.removeAttribute("required"));
await mitBegruendung.evaluate((knopf) => knopf.removeAttribute("disabled"));
await mitBegruendung.click();
await expect(zeile.getByRole("alert")).toContainText("braucht eine Begründung");
expect(await prisma.schrittergebnis.count({ where: { laufId, zustand: "NICHT_ZUTREFFEND" } })).toBe(0);
// Mit Begründung geht es – und danach steht sie in der Datenbank.
await zeile.locator('textarea[name="notiz"]').fill(NOTIZ);
await zeile.getByRole("button", { name: "Mit Begründung speichern" }).click();
await expect(pille).toHaveAttribute("aria-pressed", "true");
await expect
.poll(
async () =>
prisma.schrittergebnis.findFirst({
where: { laufId, vorlagenSchritt: { titel: UEBERSPRUNGEN } },
select: { zustand: true, notiz: true },
}),
{ timeout: 30_000, message: `Der Schritt „${UEBERSPRUNGEN}“ steht nicht als übersprungen da.` },
)
.toEqual({ zustand: "NICHT_ZUTREFFEND", notiz: NOTIZ });
await page.reload();
await expect(zeile.locator('textarea[name="notiz"]')).toHaveValue(NOTIZ);
// ---- Den Rest abhaken --------------------------------------------------
const abschnitte = abschnitteVon(page);
const anzahlAbschnitte = await abschnitte.count();
expect(anzahlAbschnitte).toBeGreaterThan(0);
for (let nummer = 0; nummer < anzahlAbschnitte; nummer += 1) {
const abschnitt = abschnitte.nth(nummer);
const kopf = abschnitt.locator("button[aria-expanded]").first();
// Nach dem Neuladen ist zu, was fertig ist – erst aufklappen.
if ((await kopf.getAttribute("aria-expanded")) === "false") await kopf.click();
const zeilen = abschnitt.locator("> ul > li");
const anzahlZeilen = await zeilen.count();
for (let stelle = 0; stelle < anzahlZeilen; stelle += 1) {
const schritt = zeilen.nth(stelle);
// Was schon vom Tisch ist, bleibt es: ein zweiter Klick nähme es zurück.
if ((await schritt.locator('button[aria-pressed="true"]').count()) > 0) continue;
const haken = schritt.getByRole("button", { name: "erledigt", exact: true });
if ((await haken.count()) === 0) continue;
await haken.click();
// Auf den **gespeicherten** Stand warten, nicht auf den nächsten Klick:
// ohne das trifft er einen Knopf aus dem alten Baum.
await expect(haken).toHaveAttribute("aria-pressed", "true");
}
}
// ---- Abschließen und archivieren ---------------------------------------
await expect(abschliessen).toBeEnabled();
await abschliessen.click();
const archivieren = page.getByRole("button", { name: "archivieren" });
await expect(archivieren).toBeVisible();
expect((await prisma.lauf.findUniqueOrThrow({ where: { id: laufId } })).status).toBe("ABGESCHLOSSEN");
await archivieren.click();
await page.getByRole("button", { name: "Ja, archivieren" }).click();
await expect(page.getByText(/Dieser Lauf ist archiviert/)).toBeVisible();
expect((await prisma.lauf.findUniqueOrThrow({ where: { id: laufId } })).status).toBe("ARCHIVIERT");
// ---- Im Archiv wiederfinden --------------------------------------------
await page.goto("/archiv");
await page.getByLabel("Suchbegriff").fill(NOTIZWORT);
await page.getByRole("button", { name: "Suchen" }).click();
const treffer = page.getByRole("listitem").filter({ hasText: TITEL });
await expect(treffer).toHaveCount(1);
// Und der Grund steht dabei – ein Treffer ohne sichtbare Fundstelle ist wertlos.
await expect(treffer.getByText("Notiz")).toBeVisible();
await expect(treffer.getByText(new RegExp(NOTIZWORT))).toBeVisible();
// ---- Und danach greift der Schreibschutz -------------------------------
await treffer.getByRole("link").first().click();
await page.waitForURL(`**/laeufe/${laufId}`);
await expect(page.getByText(/Dieser Lauf ist archiviert/)).toBeVisible();
/*
* **Erst aufklappen, dann prüfen.** An einem abgeschlossenen Lauf ist jeder
* Abschnitt zugeklappt, und in einem zugeklappten Abschnitt steht überhaupt
* nichts im Baum. Ein „keine Knöpfe gefunden“ wäre dort kein Nachweis für den
* Schreibschutz, sondern nur einer dafür, dass niemand hingesehen hat.
*/
const archiviert = abschnitteVon(page);
const anzahlArchiviert = await archiviert.count();
expect(anzahlArchiviert).toBeGreaterThan(0);
for (let nummer = 0; nummer < anzahlArchiviert; nummer += 1) {
const kopf = archiviert.nth(nummer).locator("button[aria-expanded]").first();
if ((await kopf.getAttribute("aria-expanded")) === "false") await kopf.click();
await expect(kopf).toHaveAttribute("aria-expanded", "true");
}
for (const knopf of [
"erledigt",
"nicht zutreffend",
"fehlgeschlagen",
"Wert speichern",
"Kommentar hinzufügen",
"Abschnitt trifft nicht zu",
"Lauf beginnen",
"Lauf abschließen",
]) {
await expect(
page.getByRole("button", { name: knopf, exact: true }),
`Am archivierten Lauf steht noch der Knopf „${knopf}“.`,
).toHaveCount(0);
}
/*
* Genau **ein** Formular bleibt stehen, und das ist das Zurückholen: das Archiv
* ist die Zusage, dass sich ein abgeschlossener Lauf nicht mehr ändert, und nur
* ein Administrator darf sie aufheben. Gezählt wird deshalb auf eins und nicht
* auf null — sonst hieße „grün“ hier irgendwann, dass auch der eine legitime
* Weg verschwunden ist.
*/
await expect(page.getByRole("button", { name: "aus dem Archiv zurückholen" })).toBeVisible();
await expect(page.locator("main form")).toHaveCount(1);
/*
* Der Wert und die Begründung stehen weiter da – lesbar, nur nicht änderbar.
* Ein Archiv, das seine Inhalte beim Sperren verliert, wäre kein Archiv.
*/
await expect(page.getByText(WERT)).toBeVisible();
await expect(page.getByText(NOTIZ)).toBeVisible();
});
});
test.afterAll(async () => {
/*
* Aufgeräumt wird über die Datenbank: für einen Lauf gibt es in der Anwendung
* keinen Löschweg, und das ist richtig so. `Protokoll.laufId` steht auf
* `Restrict` — ohne diese Zeile bleibt der Lauf stehen und der Fremdschlüssel
* meldet sich.
*/
const laeufe = await prisma.lauf.findMany({
where: { titel: { startsWith: "ZZ E2E Hauptablauf" } },
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 } } });
await prisma.protokoll.deleteMany({
where: { OR: [{ laufId: { in: kennungen } }, { entitaetId: { in: kennungen } }] },
});
await prisma.lauf.deleteMany({ where: { id: { in: kennungen } } });
}
await prisma.$disconnect();
});
+138
View File
@@ -0,0 +1,138 @@
import { expect, type Locator, type Page } from "@playwright/test";
import "dotenv/config";
/**
* Was alle Ende-zu-Ende-Abläufe teilen (#018).
*
* Bis hierher hatte jede Datei ihr eigenes `anmelden()` — zehnmal dasselbe, und
* jede Verbesserung musste zehnmal nachgezogen werden. Wichtiger als die
* Wiederholung ist aber, was hier sonst noch steht: **die Voraussetzungen eines
* Laufs, geprüft statt im Kopfkommentar behauptet.**
*
* In #017 kostete beides einen halben Abend. Die Abläufe wählten ihre Vorlage
* über die Position in der Liste, und der Testprozess schrieb seine Daten in
* eine andere Datenbank, als der Server las. Beide Male sagte die Meldung nichts
* über die Ursache: einmal „Timeout 30000ms exceeded“, einmal „0 Treffer“.
*/
/** Die Vorlage, mit der die Abläufe arbeiten — aus dem Seed, nicht aus einem Skript. */
export const SEED_VORLAGE = "PC Neuinstallation";
/** Das Beispielgerät aus dem Seed. Sein Aufkleber steht im Abnahmekriterium. */
export const SEED_GERAET = "PC-001";
export const SEED_TAG = "LZ-7K3F9Q";
export async function anmelden(seite: Page): Promise<void> {
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. Sie stehen in der .env und müssen zu dem " +
"Administrator passen, den der Seed angelegt hat.",
);
}
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"]')]);
}
/**
* Wählt eine Vorlage im Start-Dialog **am Namen**.
*
* Nicht über `{ index: 1 }`: das ist eine Annahme über die Sortierung der
* Vorlagenliste, und sie hielt genau so lange, wie es eine einzige Vorlage gab.
* Steht die gesuchte nicht zur Auswahl, sagt die Meldung, welche fehlt — nicht
* „Timeout beim Warten auf die Adresse“.
*/
export async function vorlageWaehlen(seite: Page, name: string): Promise<void> {
const optionen = await seite
.locator('select[name="vorlagenVersionId"] option')
.evaluateAll((liste) =>
liste
.filter((eintrag): eintrag is HTMLOptionElement => eintrag instanceof HTMLOptionElement)
.map((eintrag) => ({ wert: eintrag.value, text: eintrag.textContent ?? "" })),
);
const treffer = optionen.find((eintrag) => eintrag.text.includes(name));
if (!treffer) {
const vorhanden = optionen.map((eintrag) => eintrag.text).join(", ");
throw new Error(`Die Vorlage „${name}“ steht nicht zur Auswahl. Zur Wahl stehen: ${vorhanden}`);
}
await seite.selectOption('select[name="vorlagenVersionId"]', treffer.wert);
// Die Variablenfelder kommen nach, sobald der Server die Vorlage geliefert hat.
await expect(seite.locator("fieldset")).toBeVisible();
}
/**
* Füllt alle Pflichtfelder des Start-Dialogs, die noch leer sind.
*
* Bewusst **nur leere**: ein Vorschlag aus den Namensregeln soll stehen bleiben,
* sonst prüfte der Ablauf danach seine eigene Eingabe statt der Regel.
*/
export async function variablenFuellen(seite: Page, ausnahmen: readonly string[] = []): Promise<void> {
const felder = seite.locator("fieldset label");
for (let nummer = 0; nummer < (await felder.count()); nummer += 1) {
const feld = felder.nth(nummer);
const beschriftung = (await feld.locator("span").first().innerText()).replace("*", "").trim();
if (ausnahmen.some((ausnahme) => beschriftung.includes(ausnahme))) continue;
const auswahl = feld.locator("select");
if ((await auswahl.count()) > 0) {
await auswahl.selectOption({ index: 1 });
continue;
}
const eingabe = feld.locator("input");
if ((await eingabe.count()) === 0) continue;
if ((await eingabe.inputValue()) !== "") continue;
const typ = await eingabe.getAttribute("type");
if (typ === "date") await eingabe.fill("2026-10-01");
else if (typ === "number") await eingabe.fill("16");
else await eingabe.fill("E2E");
}
}
/**
* Waehlt den Kunden, wo das Formular danach fragt.
*
* Im Modus INTERN gibt es das Feld nicht — die eigene Organisation ist gesetzt,
* und der Server verwirft eine mitgeschickte Kennung ohnehin (#008). Im Modus
* DIENSTLEISTUNG ist es Pflicht, und ohne Auswahl antwortet der Dienst mit
* „Im Modus Dienstleistung muss ein Kunde gewählt werden“.
*
* `kundeId` gibt man an, wenn es auf **diesen** Kunden ankommt: ein Lauf kann
* strukturell kein Geraet und keine Abteilung eines fremden Kunden waehlen, das
* halten zusammengesetzte Fremdschluessel in der Datenbank fest. Ohne Angabe
* nimmt der Helfer den ersten — richtig ueberall dort, wo nur irgendein Kunde
* gebraucht wird.
*/
export async function kundeWaehlen(
/**
* Seite **oder** der Bereich, in dem das Feld steht. Der Bereich zaehlt, wo
* mehrere Formulare nebeneinander stehen: die Geraeteseite hat eines zum
* Anlegen und eines zum Einlesen, und `.first()` traefe dann das falsche.
*/
bereich: Page | Locator,
kundeId?: string,
): Promise<boolean> {
const feld = bereich.locator('select[name="kundeId"]').first();
if ((await feld.count()) === 0 || !(await feld.isVisible())) return false;
await feld.selectOption(kundeId ?? { index: 1 });
return true;
}
/**
* Der Betriebsmodus der Installation unter Test.
*
* Gesetzt von `global-setup.ts` aus der Datenbank. Ein Ablauf, der nur in einem
* der beiden Modi Sinn ergibt, fragt hier und ueberspringt sich mit einem Grund
* — statt in einer Oberflaeche zu suchen, die es dort nicht gibt.
*/
export function betriebsmodus(): "INTERN" | "DIENSTLEISTUNG" {
return process.env["E2E_BETRIEBSMODUS"] === "DIENSTLEISTUNG" ? "DIENSTLEISTUNG" : "INTERN";
}
/** Das Beispielgerät heisst im Modus DIENSTLEISTUNG anders — dieselbe Regel wie im Seed. */
export function geraetName(): string {
return betriebsmodus() === "DIENSTLEISTUNG" ? "MUS-PC-001" : SEED_GERAET;
}
+97 -66
View File
@@ -1,6 +1,16 @@
import { expect, test, type Page } from "@playwright/test"; import { expect, test, type Page } from "@playwright/test";
import "dotenv/config"; import "dotenv/config";
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import {
SEED_GERAET,
SEED_TAG,
SEED_VORLAGE,
anmelden,
kundeWaehlen,
variablenFuellen,
vorlageWaehlen,
} from "./helfer";
/** /**
* Ein Lauf von vorn bis hinten durch die Oberflaeche (#013). * Ein Lauf von vorn bis hinten durch die Oberflaeche (#013).
@@ -10,68 +20,57 @@ import { prisma } from "@/lib/db";
* was nur mit angehaengtem React gelingt, und danach, ob es wirklich in der * was nur mit angehaengtem React gelingt, und danach, ob es wirklich in der
* Datenbank steht: durch Neuladen. * Datenbank steht: durch Neuladen.
* *
* Voraussetzung: ein **gebauter** Stand laeuft (`pnpm build && pnpm start`) und * Voraussetzungen prueft `tests/e2e/global-setup.ts` vor dem ersten Ablauf: ein
* `pnpm beispiel:vorlage` hat die Beispielvorlage angelegt. * **gebauter** Stand antwortet, `SEED_VORLAGE` ist veroeffentlicht, das
* Beispielgeraet steht, und der Server liest dieselbe Datenbank wie dieser Lauf.
* Gearbeitet wird mit dem Bestand aus `pnpm db:seed` — die Vorlage aus
* `pnpm beispiel:vorlage` braucht hier niemand mehr (#018).
*/ */
const STEMPEL = Date.now(); const STEMPEL = Date.now();
const TITEL = `ZZ E2E Lauf ${STEMPEL}`; const TITEL = `ZZ E2E Lauf ${STEMPEL}`;
async function anmelden(seite: Page) { /**
const email = process.env["SEED_ADMIN_EMAIL"]; * Das Beispielgeraet, geholt ueber den **Tag-Code**.
const passwort = process.env["SEED_ADMIN_PASSWORD"]; *
if (!email || !passwort) throw new Error("SEED_ADMIN_EMAIL und SEED_ADMIN_PASSWORD fehlen."); * Nicht ueber `{ index: 1 }` und nicht ueber den Hostnamen: die Position in der
await seite.goto("/anmelden"); * Liste haengt an der Sortierung, und „PC-001“ gibt es in einer benutzten
await seite.fill('input[name="email"]', email); * Datenbank mehr als einmal. Der Tag-Code gehoert genau einem Geraet — und dass
await seite.fill('input[name="passwort"]', passwort); * es dieses gibt, prueft der Global-Setup.
await Promise.all([seite.waitForURL("**/"), seite.click('button[type="submit"]')]); */
async function seedGeraetId(): Promise<string> {
const geraet = await prisma.geraet.findUniqueOrThrow({
where: { tagCode: SEED_TAG },
select: { id: true },
});
return geraet.id;
} }
/** Die Vorlage aus `pnpm beispiel:vorlage` – diese Ablaeufe arbeiten mit ihr. */
const BEISPIEL_VORLAGE = "PC-Neuinstallation (Beispiel)";
/** Legt einen Lauf an und gibt seine Adresse zurueck. */ /** Legt einen Lauf an und gibt seine Adresse zurueck. */
async function laufStarten(seite: Page, titel: string): Promise<string> { async function laufStarten(seite: Page, titel: string): Promise<string> {
await seite.goto("/laeufe/starten"); await seite.goto("/laeufe/starten");
await vorlageWaehlen(seite, SEED_VORLAGE);
/* /*
* Die Vorlage wird **am Namen** gewaehlt und nicht ueber `index: 1`. Seit dem * Erst der Kunde, dann das Geraet — und zwar **der Kunde des Geraets**. Im
* Seed der sieben Vorlagen (#017) steht dort an erster Stelle, was die * Modus DIENSTLEISTUNG fragt der Dialog danach, und ein Lauf kann strukturell
* Sortierung gerade nach vorn stellt — bei einer frisch eingerichteten * kein Geraet eines fremden Kunden waehlen: der zusammengesetzte
* Installation etwa „Mitarbeiter-Offboarding“ mit Pflichtangaben, die dieser * Fremdschluessel `Lauf_geraet_gleicher_kunde` weist das ab. „Irgendein Kunde“
* Helfer nicht kennt. Der Ablauf lief dann in den Zeitablauf, und die Meldung * waere hier also nicht ungenau, sondern rot. Im Modus INTERN gibt es das Feld
* sagte nichts ueber die Ursache. * nicht.
*/ */
const vorlagen = await seite const geraetId = await seedGeraetId();
.locator('select[name="vorlagenVersionId"] option') const geraet = await prisma.geraet.findUniqueOrThrow({
.evaluateAll((liste) => where: { id: geraetId },
liste select: { kundeId: true },
.filter((option): option is HTMLOptionElement => option instanceof HTMLOptionElement) });
.map((option) => ({ wert: option.value, text: option.textContent })), await kundeWaehlen(seite, geraet.kundeId);
); // Das Geraet setzt `{{hostname}}` im Befehlsschritt – ohne es bliebe der
const beispiel = vorlagen.find((eintrag) => (eintrag.text ?? "").includes(BEISPIEL_VORLAGE)); // Platzhalter stehen, und der Test darauf pruefte nichts.
if (!beispiel) { await seite.selectOption('select[name="geraetId"]', geraetId);
throw new Error(`Die Vorlage „${BEISPIEL_VORLAGE}“ fehlt. Erst „pnpm beispiel:vorlage“ laufen lassen.`);
}
await seite.selectOption('select[name="vorlagenVersionId"]', beispiel.wert);
await seite.selectOption('select[name="geraetId"]', { index: 1 });
await seite.locator("details summary", { hasText: "Titel, Ticket" }).click(); await seite.locator("details summary", { hasText: "Titel, Ticket" }).click();
await seite.getByLabel("Titel", { exact: true }).fill(titel); await seite.getByLabel("Titel", { exact: true }).fill(titel);
// Pflichtvariablen der Vorlage fuellen – welche es sind, weiss nur die Vorlage. // Pflichtvariablen der Vorlage fuellen – welche es sind, weiss nur die Vorlage.
for (const feld of await seite.locator("fieldset select").all()) { await variablenFuellen(seite);
await feld.selectOption({ index: 1 });
}
for (const feld of await seite.locator('fieldset input[type="number"]').all()) {
await feld.fill("16");
}
for (const feld of await seite.locator('fieldset input[type="date"]').all()) {
await feld.fill("2026-10-01");
}
for (const feld of await seite.locator('fieldset input[type="text"]').all()) {
// Nur leere Felder: die Namensvorschlaege sollen stehen bleiben.
if ((await feld.inputValue()) === "") await feld.fill("E2E");
}
await seite.getByRole("button", { name: "Lauf anlegen" }).click(); await seite.getByRole("button", { name: "Lauf anlegen" }).click();
await seite.waitForURL(/\/laeufe\/(?!starten)[^/]+$/, { timeout: 30_000 }); await seite.waitForURL(/\/laeufe\/(?!starten)[^/]+$/, { timeout: 30_000 });
@@ -153,8 +152,11 @@ test.describe("Ein Lauf von vorn bis hinten", () => {
await laufStarten(page, `${TITEL} B`); await laufStarten(page, `${TITEL} B`);
await page.getByRole("button", { name: "Lauf beginnen" }).click(); await page.getByRole("button", { name: "Lauf beginnen" }).click();
// Am Schritt und nicht ueber `.first()`: welcher Schritt zuerst kommt, ist
// eine Annahme ueber die Vorlage (#018).
const schrittZuvor = page.getByRole("listitem").filter({ hasText: "Ticket und Endbenutzer" });
// Der Klick allein speichert nicht – erst erscheint das Begründungsfeld. // Der Klick allein speichert nicht – erst erscheint das Begründungsfeld.
await page.getByRole("button", { name: "nicht zutreffend" }).first().click(); await schrittZuvor.getByRole("button", { name: "nicht zutreffend" }).click();
const speichern = page.getByRole("button", { name: "Mit Begründung speichern" }); const speichern = page.getByRole("button", { name: "Mit Begründung speichern" });
await expect(speichern).toBeVisible(); await expect(speichern).toBeVisible();
await expect(speichern).toBeDisabled(); await expect(speichern).toBeDisabled();
@@ -211,15 +213,26 @@ test.describe("Ein Lauf von vorn bis hinten", () => {
await laufStarten(page, `${TITEL} C`); await laufStarten(page, `${TITEL} C`);
/* /*
* Der Befehl **am Schritt**, nicht die Skript-Kopie: die steckt in einem * Am **Schritt**, der den Platzhalter traegt, und nicht am ersten `pre.mono`
* zugeklappten `details` und ist deshalb unsichtbar, bis jemand sie * der Seite: nur dort beweist „enthaelt `{{hostname}}` nicht“ etwas. Ein
* aufklappt. * Befehl ohne Platzhalter waere auch gruen, wenn nie etwas eingesetzt wuerde.
*/ */
const befehl = page.locator("li pre.mono").first(); const schritt = page.getByRole("listitem").filter({ hasText: "Hostname gesetzt" });
const befehl = schritt.locator("pre.mono").first();
await expect(befehl).toBeVisible(); await expect(befehl).toBeVisible();
await expect(befehl).not.toContainText("{{hostname}}"); await expect(befehl).not.toContainText("{{hostname}}");
await expect(page.getByRole("button", { name: "Befehl kopieren" }).first()).toBeVisible(); // Und was stattdessen dasteht: der Name des gewaehlten Geraets.
await expect(page.getByText("Alle Befehle als Skript kopieren")).toBeVisible(); await expect(befehl).toContainText(SEED_GERAET);
await expect(schritt.getByRole("button", { name: "Befehl kopieren" })).toBeVisible();
/*
* Die Skript-Kopie steckt in einem zugeklappten `details` und steht **je
* Abschnitt**, der Befehle hat — bei dieser Vorlage in dreien. Geprueft wird
* deshalb der Abschnitt dieses Schritts, nicht „irgendwo auf der Seite“:
* ohne die Eingrenzung fand der Test drei Treffer und brach ab (#018).
*/
const abschnitt = page.locator("section").filter({ has: schritt });
await expect(abschnitt.getByText("Alle Befehle als Skript kopieren")).toBeVisible();
}); });
test("trägt am Handy ohne horizontales Scrollen", async ({ page, isMobile }) => { test("trägt am Handy ohne horizontales Scrollen", async ({ page, isMobile }) => {
@@ -285,7 +298,23 @@ test.describe("Namensvorschläge im Start-Dialog", () => {
*/ */
const VORLAGE = `ZZ E2E Onboarding ${STEMPEL}`; const VORLAGE = `ZZ E2E Onboarding ${STEMPEL}`;
/*
* Die Namensregeln des Hauses, wie sie vorher dastanden.
*
* Sie werden fuer diese beiden Ablaeufe gesetzt und hinterher **zurueckgestellt**
* — nicht auf `{}` geleert. Genau das tat der Nachlauf vorher, und damit nahm
* er der Datenbank die Regeln, die `pnpm db:seed` eingerichtet hatte: in der
* Entwicklungsdatenbank stand danach `namensregeln = {}`, und der Schnellstart
* eines Onboardings schlug ohne erkennbaren Grund fehl (#018).
*/
let namensregelnVorher: Prisma.JsonValue = {};
test.beforeAll(async () => { test.beforeAll(async () => {
const vorher = await prisma.einstellung.findUniqueOrThrow({
where: { id: "global" },
select: { namensregeln: true },
});
namensregelnVorher = vorher.namensregeln;
await prisma.einstellung.update({ await prisma.einstellung.update({
where: { id: "global" }, where: { id: "global" },
data: { data: {
@@ -318,21 +347,19 @@ test.describe("Namensvorschläge im Start-Dialog", () => {
}); });
}); });
/** 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("füllt upn, sam und mail nach den Regeln", async ({ page }) => {
test.slow(); test.slow();
await anmelden(page); await anmelden(page);
await page.goto("/laeufe/starten"); await page.goto("/laeufe/starten");
await vorlageWaehlen(page); await vorlageWaehlen(page, VORLAGE);
await page.getByLabel("… oder Name als Freitext").fill("Jörg Müller-Lüdenscheid"); await page.getByLabel("… oder Name als Freitext").fill("Jörg Müller-Lüdenscheid");
/*
* Im Modus DIENSTLEISTUNG braucht der Vorschlag einen Kunden: die Regeln
* koennen je Kunde eigene sein, und `istVergeben` fragt innerhalb seiner
* Grenze. Ohne Auswahl antwortet die Aktion mit „Im Modus Dienstleistung
* muss ein Kunde gewählt werden“ und die Felder bleiben leer.
*/
await kundeWaehlen(page);
await page.getByRole("button", { name: "Namen aus den Regeln vorschlagen" }).click(); await page.getByRole("button", { name: "Namen aus den Regeln vorschlagen" }).click();
@@ -351,8 +378,9 @@ test.describe("Namensvorschläge im Start-Dialog", () => {
test.slow(); test.slow();
await anmelden(page); await anmelden(page);
await page.goto("/laeufe/starten"); await page.goto("/laeufe/starten");
await vorlageWaehlen(page); await vorlageWaehlen(page, VORLAGE);
await page.getByLabel("… oder Name als Freitext").fill("Maria Huber"); await page.getByLabel("… oder Name als Freitext").fill("Maria Huber");
await kundeWaehlen(page);
await page.getByLabel("upn").fill("chefin@firma.de"); await page.getByLabel("upn").fill("chefin@firma.de");
await page.getByRole("button", { name: "Namen aus den Regeln vorschlagen" }).click(); await page.getByRole("button", { name: "Namen aus den Regeln vorschlagen" }).click();
@@ -367,7 +395,10 @@ test.describe("Namensvorschläge im Start-Dialog", () => {
select: { id: true }, select: { id: true },
}); });
if (vorlage) await prisma.vorlage.delete({ where: { id: vorlage.id } }); if (vorlage) await prisma.vorlage.delete({ where: { id: vorlage.id } });
await prisma.einstellung.update({ where: { id: "global" }, data: { namensregeln: {} } }); await prisma.einstellung.update({
where: { id: "global" },
data: { namensregeln: namensregelnVorher as Prisma.InputJsonValue },
});
}); });
}); });
+284
View File
@@ -0,0 +1,284 @@
import { expect, test, type Page } from "@playwright/test";
import "dotenv/config";
import { prisma } from "@/lib/db";
import { SEED_TAG, SEED_VORLAGE, anmelden, betriebsmodus } from "./helfer";
/**
* Am Handy scrollt nichts nach rechts (#018).
*
* Die Zusage steht in CLAUDE.md und im Auftrag: **bei 390 px kein horizontales
* Scrollen**, auf keiner Hauptseite. Sie ist keine Geschmacksfrage. Ein Techniker
* arbeitet einen Lauf einhändig neben einem aufgeschraubten Rechner ab; eine
* Seite, die seitwärts wegläuft, kostet ihn bei jedem Haken einen Griff.
*
* Geprüft wird das schmalste Maß, das es gibt: `scrollWidth <= clientWidth` am
* `html`-Element. Und geprüft wird es **auf allen** Hauptseiten, nicht auf einer
* Auswahl — die eine Seite, die jemand vergisst, ist die, auf der es bricht.
*
* Die Meldung nennt die Seite, die Zahlen und das breiteste Element. „expected
* true“ beschreibt zwar denselben Fehler, aber niemand kann damit etwas anfangen:
* bei einer Seite mit dreihundert Kästen ist die Frage nicht *ob*, sondern *wo*.
*
* Der Ablauf legt an, was er braucht — einen Lauf für die Abarbeitungsansicht und
* einen Entwurf für den Editor — und räumt beides weg. Ein Ablauf, der sich
* darauf verlässt, dass zufällig ein Lauf in der Datenbank steht, scheitert gegen
* eine frische Installation mit „No record was found“, und das sagt nichts über
* die Ursache (Befund aus #017).
*/
const STEMPEL = Date.now();
const LAUF_TITEL = `ZZ E2E Mobil ${STEMPEL}`;
const ENTWURF_NAME = `ZZ E2E Mobil Entwurf ${STEMPEL}`;
let laufId = "";
let geraetId = "";
let mitarbeiterId = "";
let vorlageId = "";
let entwurfId = "";
let entwurfVorlageId = "";
/*
* Nur im Projekt „handy“. Auf dem Desktop prüfte derselbe Ablauf 1440 px gegen
* 1440 px und wäre immer grün — grün ohne Aussage ist schlimmer als rot.
*/
test.skip(({ isMobile }) => !isMobile, "Nur im Projekt „handy“ — 390 px ist die Zusage.");
/**
* **390 × 844, ausdrücklich.**
*
* Das Projekt „handy“ benutzt `devices["Pixel 7"]`, und das sind 412 × 839. Die
* Zusage lautet aber auf 390 px (CLAUDE.md, „Gestaltung“; Abnahmekriterium in
* #018), und 22 px sind kein Rundungsfehler: eine Seite, die bei 390 px
* ausbricht, kann bei 412 px tadellos aussehen. Die Zahl, auf die es ankommt,
* steht deshalb hier und nicht in einem Gerätenamen, dessen Maße sich mit der
* nächsten Playwright-Fassung ändern dürfen.
*/
test.use({ viewport: { width: 390, height: 844 } });
test.beforeAll(async () => {
const geraet = await prisma.geraet.findUniqueOrThrow({
where: { tagCode: SEED_TAG },
select: { id: true, kundeId: true },
});
geraetId = geraet.id;
const person = await prisma.mitarbeiter.findFirst({
where: { aktiv: true, kundeId: geraet.kundeId },
orderBy: { anzeigename: "asc" },
select: { id: true },
});
if (!person) throw new Error("Es gibt keine aktive Person — pnpm db:seed laufen lassen.");
mitarbeiterId = person.id;
const vorlage = await prisma.vorlage.findFirstOrThrow({
where: { name: SEED_VORLAGE },
select: {
id: true,
versionen: {
where: { status: "VEROEFFENTLICHT" },
orderBy: { version: "desc" },
take: 1,
select: { id: true },
},
},
});
vorlageId = vorlage.id;
const versionId = vorlage.versionen[0]?.id;
if (!versionId) throw new Error(`Die Vorlage „${SEED_VORLAGE}“ hat keine veröffentlichte Version.`);
/*
* Ein Lauf in Arbeit: er trägt die langen Befehlszeilen und die Hilfetexte, und
* das sind die beiden Stellen, an denen eine Seite am Handy nach rechts
* ausbricht. Der Kunde kommt vom Gerät — ein zusammengesetzter Fremdschlüssel
* lässt kein Gerät eines fremden Kunden zu.
*/
const lauf = await prisma.lauf.create({
data: {
vorlagenVersionId: versionId,
kundeId: geraet.kundeId,
geraetId: geraet.id,
mitarbeiterId,
titel: LAUF_TITEL,
status: "IN_ARBEIT",
gestartetAm: new Date(),
},
select: { id: true },
});
laufId = lauf.id;
/*
* Ein eigener Entwurf für den Editor. Nicht „irgendein Entwurf“ aus der
* Datenbank: eine frische Installation bringt bewusst keinen mit (so in #017
* entschieden), und die Seed-Vorlagen zum Bearbeiten zu öffnen hinterließe
* einen Entwurf, den niemand angelegt hat.
*/
const eigene = await prisma.vorlage.create({
data: { name: ENTWURF_NAME, kategorie: "ZZ Test" },
select: { id: true },
});
entwurfVorlageId = eigene.id;
const entwurf = await prisma.vorlagenVersion.create({
data: { vorlageId: eigene.id, version: 1, status: "ENTWURF" },
select: { id: true },
});
entwurfId = entwurf.id;
await prisma.vorlagenVariable.create({
data: {
versionId: entwurf.id,
schluessel: "hostname",
beschriftung: "Hostname des neuen Geräts",
position: 0,
pflicht: true,
},
});
const abschnitt = await prisma.vorlagenAbschnitt.create({
data: { versionId: entwurf.id, titel: "Vorbereitung", position: 0 },
select: { id: true },
});
await prisma.vorlagenSchritt.create({
data: {
abschnittId: abschnitt.id,
titel: "Rechner umbenennen",
position: 0,
typ: "BEFEHL",
pflicht: true,
// Absichtlich lang: ein kurzer Befehl bricht keine Seite auf.
konfig: {
shell: "powershell",
befehl: 'Rename-Computer -NewName "{{hostname}}" -DomainCredential (Get-Credential) -Restart -Force',
},
},
});
});
/** Eine Hauptseite. Die Adresse kommt als Funktion, weil die Kennungen erst in `beforeAll` entstehen. */
type Hauptseite = {
readonly name: string;
readonly pfad: () => string;
/** Nur in diesem Betriebsmodus vorhanden. Sonst gibt es die Seite nicht. */
readonly nurModus?: "INTERN" | "DIENSTLEISTUNG";
};
const SEITEN: readonly Hauptseite[] = [
{ name: "Übersicht", pfad: () => "/" },
{ name: "Läufe", pfad: () => "/laeufe" },
{ name: "Lauf-Detail", pfad: () => `/laeufe/${laufId}` },
{ name: "Lauf starten", pfad: () => "/laeufe/starten" },
{ name: "Vorlagen", pfad: () => "/vorlagen" },
{ name: "Vorlagen-Detail", pfad: () => `/vorlagen/${vorlageId}` },
{ name: "Vorlagen-Editor", pfad: () => `/vorlagen/version/${entwurfId}` },
{ name: "Mitarbeiter", pfad: () => "/mitarbeiter" },
{ name: "Mitarbeiter-Detail", pfad: () => `/mitarbeiter/${mitarbeiterId}` },
{ name: "Geräte", pfad: () => "/geraete" },
{ name: "Geräteseite", pfad: () => `/geraete/${geraetId}` },
{ name: "Archiv", pfad: () => "/archiv" },
{ name: "Tags", pfad: () => "/tags" },
{ name: "Scannen", pfad: () => "/scannen" },
{ name: "Admin: Einstellungen", pfad: () => "/admin" },
{ name: "Admin: Benutzer", pfad: () => "/admin/benutzer" },
{ name: "Admin: Abteilungen", pfad: () => "/admin/abteilungen" },
/*
* Das Verzeichnis gibt es nur im Modus INTERN (#008); im Modus DIENSTLEISTUNG
* leitet die Seite auf `/admin` um. Sie dort zu messen hiesse, die Umleitung
* zu messen — die scrollt garantiert nicht.
*/
{ name: "Admin: Verzeichnis", pfad: () => "/admin/verzeichnis", nurModus: "INTERN" },
{ name: "Kunden", pfad: () => "/kunden", nurModus: "DIENSTLEISTUNG" },
];
type Messung = {
readonly scroll: number;
readonly sichtbar: number;
readonly uebeltaeter: readonly string[];
};
/**
* Misst die Seite und benennt, was zu weit rechts endet.
*
* Gemeldet wird nur das **äußerste** Element je Kette: ein Kind, dessen Elter
* schon zu breit ist, sagt nichts Neues und würde die Meldung mit dreißig
* Einträgen zuschütten, die alle dieselbe Ursache haben.
*/
async function messen(seite: Page): Promise<Messung> {
return seite.evaluate(() => {
const wurzel = document.documentElement;
const sichtbar = wurzel.clientWidth;
const gemeldet: Element[] = [];
for (const element of Array.from(document.body.querySelectorAll("*"))) {
const kasten = element.getBoundingClientRect();
if (kasten.width === 0 && kasten.height === 0) continue;
// Ein Pixel Spielraum: gebrochene Bildschirmbreiten runden.
if (kasten.right <= sichtbar + 1) continue;
if (gemeldet.some((frueher) => frueher.contains(element))) continue;
gemeldet.push(element);
}
const benennen = (element: Element): string => {
const klassen = (element.getAttribute("class") ?? "")
.split(/\s+/u)
.filter(Boolean)
.slice(0, 3)
.join(".");
const text = (element.textContent ?? "").trim().replace(/\s+/gu, " ").slice(0, 40);
const breite = Math.round(element.getBoundingClientRect().right);
return `<${element.tagName.toLowerCase()}${klassen ? `.${klassen}` : ""}> endet bei ${breite} px${
text ? ` („${text}“)` : ""
}`;
};
return {
scroll: wurzel.scrollWidth,
sichtbar,
uebeltaeter: gemeldet.slice(0, 3).map(benennen),
};
});
}
test.describe("Am Handy bei 390 px", () => {
for (const hauptseite of SEITEN) {
test(`scrollt „${hauptseite.name}“ nicht horizontal`, async ({ page }) => {
test.slow();
test.skip(
hauptseite.nurModus !== undefined && hauptseite.nurModus !== betriebsmodus(),
`„${hauptseite.name}“ gibt es nur im Modus ${hauptseite.nurModus}.`,
);
await anmelden(page);
const pfad = hauptseite.pfad();
await page.goto(pfad);
/*
* Erst prüfen, dass die Seite überhaupt die gemeinte ist: eine
* Weiterleitung auf `/anmelden` scrollt garantiert nicht, und der Ablauf
* hätte grün gemeldet, ohne die Seite gesehen zu haben.
*/
expect(new URL(page.url()).pathname, `${pfad} hat weitergeleitet.`).toBe(pfad);
await page.waitForLoadState("load");
await expect(page.locator("main, body").first()).toBeVisible();
const masse = await messen(page);
expect(
masse.scroll,
`„${hauptseite.name}“ (${pfad}) scrollt horizontal: erwartet <= ${masse.sichtbar}, ` +
`war ${masse.scroll} — ${masse.scroll - masse.sichtbar} px zu breit.` +
(masse.uebeltaeter.length > 0 ? `\n Zu weit rechts: ${masse.uebeltaeter.join("\n ")}` : ""),
).toBeLessThanOrEqual(masse.sichtbar);
});
}
});
test.afterAll(async () => {
if (laufId) {
await prisma.kommentar.deleteMany({ where: { laufId } });
await prisma.schrittergebnis.deleteMany({ where: { laufId } });
// `Protokoll.laufId` steht auf `Restrict` – sonst bleibt der Lauf stehen.
await prisma.protokoll.deleteMany({ where: { OR: [{ laufId }, { entitaetId: laufId }] } });
await prisma.lauf.deleteMany({ where: { id: laufId } });
}
if (entwurfVorlageId) {
// Ein Entwurf lässt sich löschen; eine veröffentlichte Version nie. Deshalb
// wird hier einer angelegt und keiner veröffentlicht.
await prisma.vorlage.deleteMany({ where: { id: entwurfVorlageId } });
}
await prisma.$disconnect();
});
+367
View File
@@ -0,0 +1,367 @@
import { expect, test, type Page } from "@playwright/test";
import "dotenv/config";
import { prisma } from "@/lib/db";
import { PASSWORT_IM_DRUCK, PASSWORT_LAENGE } from "@/lib/passwort";
import { anmelden } from "./helfer";
/**
* Der Onboarding-Ablauf (#018).
*
* Er prüft die Kette, an der im Auftrag das meiste hängt: aus einem Namen werden
* über die Namensregeln UPN, Anmeldename und Mail-Adresse, aus der gewählten
* Abteilung werden Ziel-OU und Standardgruppen, und aus allem zusammen entsteht
* ein `New-ADUser`-Befehl, den ein Techniker auf einem Domänencontroller
* ausführt.
*
* Zwei Zusagen werden hier eingelöst, und beide nur hier:
*
* 1. **Der Kopier-Knopf liefert zeichengenau das, was auf dem Bildschirm
* steht.** Geprüft wird über die Zwischenablage, nicht über das erwartete
* Ergebnis: ein Vergleich gegen einen selbst zusammengebauten Text prüfte, ob
* der Test rechnen kann, nicht ob der Knopf richtig kopiert. Ein Befehl, der
* beim Kopieren etwas verliert — eine Zeile, ein Fluchtzeichen —, tut auf dem
* Domänencontroller etwas anderes als er liest.
* 2. **`{{generiertes_passwort}}` wird nie gespeichert.** Nicht im
* `Schrittergebnis`, nicht im `Kommentar`, nicht im `Protokoll` und nicht im
* Ausdruck. Das ist die härteste Zusage des Auftrags, und sie lässt sich nur
* so prüfen: das angezeigte Passwort auslesen und danach in der Datenbank
* danach suchen.
*/
const STEMPEL = Date.now();
const TITEL = `ZZ E2E Onboarding ${STEMPEL}`;
const VORLAGE = "Mitarbeiter-Onboarding";
const ABTEILUNG = "Buchhaltung";
/*
* Der Name der neuen Person. Kein „ZZ E2E“ davor: aus ihm entstehen Anmeldenamen
* nach den Namensregeln, und ein Leerzeichen im Vornamen wäre eine Prüfung der
* Regeln und nicht des Ablaufs. Angelegt wird ohnehin nichts — die Person steht
* beim Onboarding noch nicht im Verzeichnis, sie ist nur Freitext am Lauf.
*/
const VORNAME = "Zoe";
const NACHNAME = "Testfall";
/**
* Was die Abteilung in die Befehle speist – dieselben Werte wie im Seed
* (`prisma/seed-daten/stammdaten.ts`).
*/
const ZIEL_OU = "OU=Buchhaltung,OU=Benutzer,DC=firma,DC=local";
const GRUPPEN = "G-Buchhaltung, G-Alle";
/**
* Die Namensregeln, die dieser Ablauf braucht — er setzt sie selbst.
*
* Er darf sie nicht voraussetzen: `Einstellung.namensregeln` sind Betriebsdaten,
* ein Administrator ändert sie, und ein anderer Ablauf hat sie schon einmal
* geleert. Stehen sie leer, antwortet „Namen aus den Regeln vorschlagen“ mit
* „Für diesen Kunden sind keine Namensregeln hinterlegt“ — und der Ablauf
* scheiterte an einer Voraussetzung statt an einem Fehler. Die Werte sind
* dieselben, die `prisma/seed-daten/grundbestand.ts` anlegt.
*/
const REGELN = {
upn: "{v}.{nachname}@firma.example",
mail: "{v}.{nachname}@firma.example",
sam: "{v}{nachname}",
};
/** Was vor dem Ablauf in der Einstellung stand – wird hinterher zurückgelegt. */
let regelnVorher: unknown = null;
/**
* Der Kunde, dem die Abteilung „Buchhaltung“ gehört.
*
* Im Modus DIENSTLEISTUNG fragt der Start-Dialog nach einem Kunden, und die
* Datenbank verlangt, dass es **derselbe** ist wie der der gewählten Abteilung:
* `Lauf_abteilung_gleicher_kunde` lässt keine Abteilung eines fremden Kunden zu.
* Ein „nimm den ersten Kunden“ wäre dort nicht ungenau, sondern rot.
*/
let abteilungsKunde = "";
/** Die Adresse des Laufs, den der erste Ablauf anlegt. */
let laufPfad = "";
test.describe.configure({ mode: "serial" });
test.beforeAll(async () => {
const abteilung = await prisma.abteilung.findFirst({
where: { name: ABTEILUNG },
select: { zielOu: true, standardGruppen: true, kunde: { select: { name: true } } },
});
if (!abteilung) {
throw new Error(
`Die Abteilung „${ABTEILUNG}“ fehlt. Sie kommt aus dem Seed — pnpm db:seed laufen lassen.`,
);
}
abteilungsKunde = abteilung.kunde.name;
// Lieber hier scheitern als später an einem Befehl, dessen -Path nicht stimmt:
// die Erwartung unten und die Datenbank müssen dasselbe sagen.
expect(abteilung.zielOu).toBe(ZIEL_OU);
expect(abteilung.standardGruppen.join(", ")).toBe(GRUPPEN);
const einstellung = await prisma.einstellung.findUniqueOrThrow({
where: { id: "global" },
select: { namensregeln: true },
});
regelnVorher = einstellung.namensregeln;
await prisma.einstellung.update({ where: { id: "global" }, data: { namensregeln: REGELN } });
});
/** Der Befehl eines Schritts, genau so wie er im `pre` steht – ohne jede Normalisierung. */
async function befehlstext(seite: Page, enthaelt: string): Promise<string> {
const zeile = seite.getByRole("listitem").filter({ hasText: enthaelt });
const text = await zeile
.locator("pre.mono")
.first()
.evaluate((feld) => feld.textContent ?? "");
return text;
}
/** Das Passwort aus dem `New-ADUser`-Befehl. */
function passwortAus(befehl: string): string {
const treffer = /ConvertTo-SecureString "([^"]+)"/u.exec(befehl);
if (!treffer?.[1]) {
throw new Error(`Im Befehl steht kein Passwort. Der Befehl lautet:\n${befehl}`);
}
return treffer[1];
}
test.describe("Onboarding", () => {
test("schlägt Namen vor, füllt OU und Gruppen und kopiert den Befehl zeichengenau", async ({
page,
context,
}) => {
test.setTimeout(180_000);
/*
* Ohne dieses Recht wirft `navigator.clipboard.writeText` in Chromium, und
* der Knopf fängt das ab (der Text bleibt markierbar) — der Ablauf sähe einen
* Knopf, der scheinbar funktioniert, und eine leere Zwischenablage.
*/
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await anmelden(page);
await page.goto("/laeufe/starten");
// ---- Vorlage am Namen wählen -------------------------------------------
const optionen = await page
.locator('select[name="vorlagenVersionId"] option')
.evaluateAll((liste) =>
liste
.filter((eintrag): eintrag is HTMLOptionElement => eintrag instanceof HTMLOptionElement)
.map((eintrag) => ({ wert: eintrag.value, text: eintrag.textContent ?? "" })),
);
const treffer = optionen.find((eintrag) => eintrag.text.includes(VORLAGE));
if (!treffer) {
throw new Error(
`Die Vorlage „${VORLAGE}“ steht nicht zur Auswahl. Zur Wahl stehen: ` +
`${optionen.map((eintrag) => eintrag.text).join(", ")}`,
);
}
await page.selectOption('select[name="vorlagenVersionId"]', treffer.wert);
const angaben = page.locator("fieldset");
await expect(angaben).toBeVisible();
// ---- Vorname und Nachname eintragen ------------------------------------
/*
* Der Vorschlag entsteht aus dem **Freitextnamen**, nicht aus den
* Vorlagenvariablen: beim Onboarding steht die Person noch nicht im
* Verzeichnis, und der Dialog fragt genau dafür nach einem Namen.
*/
await page.getByLabel("… oder Name als Freitext").fill(`${VORNAME} ${NACHNAME}`);
await angaben.getByLabel(/^Kontotyp/u).selectOption("Active Directory");
await angaben.getByLabel(/^Vorname/u).fill(VORNAME);
await angaben.getByLabel(/^Nachname/u).fill(NACHNAME);
await angaben.getByLabel(/^Abteilung/u).fill(ABTEILUNG);
await angaben.getByLabel(/^Eintrittsdatum/u).fill("2026-10-01");
/*
* Im Modus DIENSTLEISTUNG verlangt der Dialog einen Kunden — und es muss der
* der gewählten Abteilung sein, sonst weist die Datenbank den Lauf ab
* (`Lauf_abteilung_gleicher_kunde`).
*
* Gewählt wird er **vor** dem Namensvorschlag: die Namensregeln können je
* Kunde eigene sein, und ohne Kunden antwortet die Aktion mit „Im Modus
* Dienstleistung muss ein Kunde gewählt werden“ — die Felder blieben leer,
* und der Test läse das als fehlende Regel.
*/
const kundenWahl = page.locator('select[name="kundeId"]');
if ((await kundenWahl.count()) > 0) await kundenWahl.selectOption({ label: abteilungsKunde });
// ---- Namen aus den Regeln vorschlagen ----------------------------------
await page.getByRole("button", { name: "Namen aus den Regeln vorschlagen" }).click();
/*
* Die Zahl am Ende ist kein Zufall: trägt die Datenbank den Namensstamm
* schon, hängt die Regel eine Unterscheidung an. Ob mit oder ohne entscheidet
* der Bestand, deshalb steht hier ein Muster und keine Zeichenkette.
*/
await expect(angaben.getByLabel(/^UPN/u)).toHaveValue(/^z\.testfall\d*@firma\.example$/u);
await expect(angaben.getByLabel(/^Anmeldename/u)).toHaveValue(/^ztestfall\d*$/u);
await expect(angaben.getByLabel(/^Mail-Adresse/u)).toHaveValue(/^z\.testfall\d*@firma\.example$/u);
const upn = await angaben.getByLabel(/^UPN/u).inputValue();
const sam = await angaben.getByLabel(/^Anmeldename/u).inputValue();
const mail = await angaben.getByLabel(/^Mail-Adresse/u).inputValue();
// Ziel-OU und Gruppen bleiben **leer**: sie sollen aus der Abteilung kommen.
await expect(angaben.getByLabel(/^Ziel-OU/u)).toHaveValue("");
await expect(angaben.getByLabel(/^Gruppen/u)).toHaveValue("");
// ---- Abteilung im aufklappbaren Teil wählen ----------------------------
const weitere = page.locator("details").filter({ hasText: "Titel, Ticket" });
await weitere.locator("summary").click();
await weitere.locator('input[name="titel"]').fill(TITEL);
await weitere.locator('select[name="abteilungId"]').selectOption({ label: ABTEILUNG });
await page.getByRole("button", { name: "Lauf anlegen" }).click();
await page.waitForURL(/\/laeufe\/(?!starten)[^/]+$/, { timeout: 60_000 });
laufPfad = new URL(page.url()).pathname;
const laufId = laufPfad.split("/").pop() as string;
// ---- Der fertige New-ADUser-Befehl -------------------------------------
const anlegen = page.getByRole("listitem").filter({ hasText: "New-ADUser" });
await expect(anlegen).toHaveCount(1);
const befehl = await befehlstext(page, "New-ADUser");
expect(befehl).toContain(`New-ADUser -Name "${VORNAME} ${NACHNAME}"`);
expect(befehl).toContain(`-GivenName "${VORNAME}"`);
expect(befehl).toContain(`-Surname "${NACHNAME}"`);
expect(befehl).toContain(`-SamAccountName "${sam}"`);
expect(befehl).toContain(`-UserPrincipalName "${upn}"`);
expect(befehl).toContain(`-EmailAddress "${mail}"`);
expect(befehl).toContain(`-Department "${ABTEILUNG}"`);
// Das Abnahmekriterium, wörtlich: die Ziel-OU kommt aus der Abteilung.
expect(befehl).toContain(`-Path "${ZIEL_OU}"`);
// Und kein Platzhalter ist stehen geblieben.
expect(befehl).not.toContain("{{");
// Der Gruppen-Befehl trägt die Standardgruppen der Abteilung.
const gruppenBefehl = await befehlstext(page, "Add-ADGroupMember");
expect(gruppenBefehl).toContain(`"${GRUPPEN}".Split(",")`);
expect(gruppenBefehl).toContain(`-Members "${sam}"`);
// ---- Genau ein generiertes Passwort ------------------------------------
const passwort = passwortAus(befehl);
expect(passwort).toHaveLength(PASSWORT_LAENGE);
/*
* Ein Passwort, nicht zwei: wer `{{generiertes_passwort}}` zweimal schreibt,
* meint dasselbe. Zwei verschiedene hießen — das Konto bekommt A, der
* Benutzer erfährt B.
*/
expect(befehl.split(passwort)).toHaveLength(2);
// Und der Hinweis steht daneben, weil es nirgends gespeichert wird.
await expect(anlegen.getByText(/Passwort sofort in den Passwortmanager/)).toBeVisible();
// ---- Der Kern: die Zwischenablage, zeichengenau ------------------------
await anlegen.getByRole("button", { name: "Befehl kopieren" }).click();
await expect(anlegen.getByRole("button", { name: "Kopiert." })).toBeVisible();
const zwischenablage = await page.evaluate(() => navigator.clipboard.readText());
// `toBe` und nicht `toContain`: ein verlorenes Fluchtzeichen oder ein
// umgewandelter Zeilenumbruch ändert den Befehl, ohne ihn falsch aussehen zu
// lassen.
expect(zwischenablage).toBe(befehl);
// ---- Das Passwort steht nirgends in der Datenbank ----------------------
/*
* Erst einen Schritt speichern: das ist der Vorgang, bei dem etwas in
* `Schrittergebnis` und `Protokoll` geschrieben wird. Ohne ihn prüfte der
* folgende Abschnitt leere Tabellen.
*/
await anlegen.getByRole("button", { name: "erledigt", exact: true }).click();
// Auf die Datenbank warten, nicht auf den Bildschirm: der Protokolleintrag
// entsteht in derselben Transaktion, und genau den soll die Suche unten sehen.
await expect
.poll(async () => prisma.schrittergebnis.count({ where: { laufId, zustand: "ERLEDIGT" } }), {
timeout: 30_000,
message: "Der Schritt „Konto anlegen“ steht nicht als erledigt in der Datenbank.",
})
.toBeGreaterThan(0);
const ergebnisse = await prisma.schrittergebnis.findMany({
where: { laufId },
select: { wert: true, notiz: true },
});
const kommentare = await prisma.kommentar.findMany({ where: { laufId }, select: { rumpf: true } });
const protokolle = await prisma.protokoll.findMany({
where: { OR: [{ laufId }, { entitaetId: laufId }] },
select: { aktion: true, vorher: true, nachher: true },
});
expect(JSON.stringify({ ergebnisse, kommentare, protokolle })).not.toContain(passwort);
/*
* Und zur Sicherheit über **alle** Zeilen, nicht nur die dieses Laufs: ein
* Passwort, das an einer ganz anderen Stelle landet, wäre genauso schlimm.
* `strpos` statt `LIKE`: das Alphabet enthält `_`, und das ist in `LIKE` ein
* Platzhalter — ein Treffer wäre dann Zufall und ein Nicht-Treffer nichts wert.
*/
const spuren = await prisma.$queryRaw<
readonly { readonly ergebnisse: bigint; readonly kommentare: bigint; readonly protokoll: bigint }[]
>`
SELECT
(SELECT count(*) FROM "Schrittergebnis"
WHERE strpos(coalesce("notiz", '') || coalesce("wert"::text, ''), ${passwort}) > 0) AS "ergebnisse",
(SELECT count(*) FROM "Kommentar"
WHERE strpos("rumpf", ${passwort}) > 0) AS "kommentare",
(SELECT count(*) FROM "Protokoll"
WHERE strpos("aktion" || coalesce("vorher"::text, '') || coalesce("nachher"::text, ''), ${passwort}) > 0) AS "protokoll"
`;
expect({
ergebnisse: Number(spuren[0]?.ergebnisse),
kommentare: Number(spuren[0]?.kommentare),
protokoll: Number(spuren[0]?.protokoll),
}).toEqual({ ergebnisse: 0, kommentare: 0, protokoll: 0 });
// ---- Auch nicht auf dem Ausdruck ---------------------------------------
await page.goto(`/drucken/lauf/${laufId}`);
const blatt = (await page.locator("body").innerText()).replace(/\s+/gu, " ");
expect(blatt).not.toContain(passwort);
expect(blatt).toContain(PASSWORT_IM_DRUCK);
});
test("erzeugt bei jedem Aufbau ein anderes Passwort", async ({ page }) => {
/*
* Die Kehrseite der Zusage, und sie gehört geprüft: weil nichts gespeichert
* wird, gibt es auch keinen Weg, dasselbe Passwort noch einmal zu erfahren.
* Wer es nicht sofort weitergibt, hat es verloren — und der Techniker muss
* sich darauf verlassen können, dass das nicht heimlich anders ist.
*/
expect(laufPfad, "Der vorige Ablauf hat keinen Lauf angelegt.").not.toBe("");
await anmelden(page);
await page.goto(laufPfad);
const erstes = passwortAus(await befehlstext(page, "New-ADUser"));
await page.reload();
const zweites = passwortAus(await befehlstext(page, "New-ADUser"));
expect(zweites).not.toBe(erstes);
expect(zweites).toHaveLength(PASSWORT_LAENGE);
});
});
test.afterAll(async () => {
const laeufe = await prisma.lauf.findMany({
where: { titel: { startsWith: "ZZ E2E Onboarding" } },
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` – sonst bleibt der Lauf stehen.
await prisma.protokoll.deleteMany({
where: { OR: [{ laufId: { in: kennungen } }, { entitaetId: { in: kennungen } }] },
});
await prisma.lauf.deleteMany({ where: { id: { in: kennungen } } });
}
// Die Namensregeln zurücklegen, wie sie waren – sie sind Betriebsdaten.
if (regelnVorher !== null) {
await prisma.einstellung.update({
where: { id: "global" },
data: { namensregeln: regelnVorher as Record<string, string> },
});
}
await prisma.$disconnect();
});
+269
View File
@@ -0,0 +1,269 @@
import { hash } from "@node-rs/argon2";
import { expect, test, type Page } from "@playwright/test";
import "dotenv/config";
import { prisma } from "@/lib/db";
import { geraetName, SEED_TAG, SEED_VORLAGE } from "./helfer";
/**
* Was ein `LESER` sieht und was der Server ihm verweigert (#018).
*
* Die Rechtematrix selbst prüft `tests/rollen.test.ts`, Rolle für Rolle gegen
* jedes Recht. Hier geht es um die zwei Richtungen, die eine Tabelle nicht
* prüfen kann:
*
* 1. **Die Oberfläche bietet nichts an, was der Server verbietet.** Ein Knopf,
* der zu einer Fehlermeldung führt, ist ein Versprechen, das nicht gehalten
* wird — und wer ihn sieht, hält sich für berechtigt.
* 2. **Der Server verbietet, was die Oberfläche nicht anbietet.** Das Ausblenden
* ist Bequemlichkeit, keine Absicherung: wer die Adresse kennt, ruft sie
* direkt auf. Geprüft wird deshalb jede Adresse, hinter der geschrieben wird.
*
* Der Ablauf legt seinen Leser selbst an — mit `@node-rs/argon2`, genau wie
* `prisma/seed-daten/grundbestand.ts` — und räumt ihn hinterher weg. Ein Konto
* mit Leserechten in einer Datenbank stehen zu lassen, die weiterbenutzt wird,
* wäre eine Hinterlassenschaft, die niemand erwartet.
*/
const STEMPEL = Date.now();
const LESER_EMAIL = `zz-e2e-leser-${STEMPEL}@firma.example`;
const LESER_NAME = `ZZ E2E Leser ${STEMPEL}`;
const LESER_PASSWORT = `ZZ-E2E-Leser-${STEMPEL}`;
const LAUF_TITEL = `ZZ E2E Rollen ${STEMPEL}`;
let leserId = "";
let laufId = "";
let geraetId = "";
let vorlageId = "";
test.beforeAll(async () => {
// Das Passwort wird nur als argon2-Hash gespeichert, nie im Klartext.
const leser = await prisma.benutzer.create({
data: {
email: LESER_EMAIL,
name: LESER_NAME,
rolle: "LESER",
passwortHash: await hash(LESER_PASSWORT),
aktiv: true,
},
select: { id: true },
});
leserId = leser.id;
/*
* Ein Lauf, an dem sich der Schreibschutz zeigen lässt. Angelegt über Prisma
* und nicht über die Oberfläche: hier geht es um die Rechte und nicht um den
* Weg zum Lauf — den prüft `hauptablauf.spec.ts`. Der Kunde kommt vom Gerät,
* weil ein zusammengesetzter Fremdschlüssel keinen Lauf mit dem Gerät eines
* fremden Kunden zulässt.
*/
const geraet = await prisma.geraet.findUniqueOrThrow({
where: { tagCode: SEED_TAG },
select: { id: true, kundeId: true },
});
geraetId = geraet.id;
const vorlage = await prisma.vorlage.findFirstOrThrow({
where: { name: SEED_VORLAGE },
select: {
id: true,
versionen: {
where: { status: "VEROEFFENTLICHT" },
orderBy: { version: "desc" },
take: 1,
select: { id: true },
},
},
});
vorlageId = vorlage.id;
const versionId = vorlage.versionen[0]?.id;
if (!versionId) {
throw new Error(`Die Vorlage „${SEED_VORLAGE}“ hat keine veröffentlichte Version.`);
}
const lauf = await prisma.lauf.create({
data: {
vorlagenVersionId: versionId,
kundeId: geraet.kundeId,
geraetId: geraet.id,
titel: LAUF_TITEL,
status: "IN_ARBEIT",
// `Lauf_begonnen_mit_zeitpunkt` verlangt den Zeitstempel zum Status.
gestartetAm: new Date(),
},
select: { id: true },
});
laufId = lauf.id;
});
async function alsLeserAnmelden(seite: Page): Promise<void> {
await seite.goto("/anmelden");
await seite.fill('input[name="email"]', LESER_EMAIL);
await seite.fill('input[name="passwort"]', LESER_PASSWORT);
await Promise.all([seite.waitForURL("**/"), seite.click('button[type="submit"]')]);
}
test.describe("Ein Leser", () => {
test("sieht auf Läufen, Vorlagen und Geräten keine Schreib-Knöpfe", async ({ page }) => {
test.slow();
await alsLeserAnmelden(page);
// ---- Läufe --------------------------------------------------------------
await page.goto("/laeufe");
await expect(page.getByRole("heading", { name: "Läufe" })).toBeVisible();
await expect(page.getByRole("link", { name: "Lauf starten" })).toHaveCount(0);
// ---- Ein Lauf in Arbeit -------------------------------------------------
await page.goto(`/laeufe/${laufId}`);
await expect(page.getByRole("heading", { name: LAUF_TITEL })).toBeVisible();
for (const knopf of [
"Lauf beginnen",
"Lauf abschließen",
"archivieren",
"erledigt",
"nicht zutreffend",
"fehlgeschlagen",
"Wert speichern",
"Kommentar hinzufügen",
"Abschnitt trifft nicht zu",
]) {
await expect(
page.getByRole("button", { name: knopf, exact: true }),
`Ein Leser sieht auf dem Lauf den Knopf „${knopf}“.`,
).toHaveCount(0);
}
/*
* Und zwar wirklich keinen Schreibweg: im Inhaltsbereich steht für einen Leser
* **kein einziges** Formular. Die Aufzählung oben prüft Beschriftungen, dieses
* eine prüft, dass keine übersehen wurde. Gezählt wird in `main`, nicht auf der
* ganzen Seite — das Abmelden-Formular der Hülle steht daneben und gehört jedem.
*/
await expect(page.locator("main form")).toHaveCount(0);
// Die Angaben selbst stehen trotzdem da – lesen darf er ja.
await expect(page.getByRole("progressbar")).toBeVisible();
// ---- Vorlagen -----------------------------------------------------------
await page.goto("/vorlagen");
await expect(page.getByRole("heading", { name: "Vorlagen" })).toBeVisible();
await expect(page.getByRole("button", { name: "Vorlage anlegen" })).toHaveCount(0);
await expect(page.getByText("Vorlage aus einer Datei einspielen")).toHaveCount(0);
await page.goto(`/vorlagen/${vorlageId}`);
await expect(page.getByRole("button", { name: /bearbeiten/iu })).toHaveCount(0);
await expect(page.getByRole("button", { name: "archivieren" })).toHaveCount(0);
await expect(page.getByRole("heading", { name: "Kopfdaten" })).toHaveCount(0);
// ---- Geräte -------------------------------------------------------------
await page.goto("/geraete");
await expect(page.getByRole("heading", { name: "Geräte" })).toBeVisible();
await expect(page.getByRole("button", { name: "Gerät anlegen" })).toHaveCount(0);
await expect(page.getByText("Geräte aus einer CSV-Datei einlesen")).toHaveCount(0);
await page.goto(`/geraete/${geraetId}`);
await expect(page.getByRole("heading", { name: new RegExp(geraetName()) })).toBeVisible();
// „Vorlage zuweisen“ und die Schnellstart-Knöpfe hängen an `laeufeBearbeiten`.
await expect(page.getByRole("heading", { name: "Vorlage zuweisen" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Starten" })).toHaveCount(0);
// ---- Mitarbeiter --------------------------------------------------------
await page.goto("/mitarbeiter");
await expect(page.getByText("Person von Hand anlegen")).toHaveCount(0);
/*
* Und die Verwaltung steht gar nicht in der Navigation. Gesucht wird **in**
* `nav` und mit `exact`: auf der Personenliste steht ein „Systemadministrator“
* als Berufsbezeichnung, und ein unscharfes „Admin“ traf ihn mit.
*/
if (test.info().project.name === "handy") await page.getByLabel("Menü").click();
await expect(page.locator("nav").getByRole("link", { name: "Admin", exact: true })).toHaveCount(0);
});
test("wird abgewiesen, wenn er die Adresse direkt aufruft", async ({ page }) => {
test.slow();
await alsLeserAnmelden(page);
/*
* `/laeufe/starten` verlangt nur `lesen`, damit ein abgelaufenes Lesezeichen
* keine 500er-Seite ergibt — und schickt dann zurück zur Liste. Wer keine
* Läufe bearbeiten darf, soll die Seite nicht als vorhanden erleben.
*/
await page.goto("/laeufe/starten");
await expect(page).toHaveURL(/\/laeufe$/u);
await expect(page.getByRole("button", { name: "Lauf anlegen" })).toHaveCount(0);
/*
* Alles unter `verwaltung` weist der Server ab. Kein geworfener Fehler,
* sondern eine Weiterleitung: für jemanden, der ein altes Lesezeichen öffnet,
* wäre eine 500er-Seite die falsche Auskunft.
*
* Geprüft wird beides: dass er **nicht auf der Seite landet** und dass er
* **erfährt, warum**. Bis #018 schickte `sitzungErforderlich` ihn nach
* `/anmelden?fehler=keinrecht` — und die Anmeldeseite schickte einen bereits
* Angemeldeten sofort weiter auf `/`. Die Erklärung war damit unerreichbar,
* er stand ohne Hinweis auf der Übersicht und wusste nicht, ob er sich
* verklickt hatte oder ob etwas kaputt ist. Seitdem führt der Weg auf
* `/kein-zugriff`.
*/
for (const adresse of [
"/admin",
"/admin/benutzer",
"/admin/abteilungen",
"/admin/verzeichnis",
"/tags",
// Der Etikettenbogen **ohne** Codes zeigt den ganzen Vorrat an noch nicht
// zugeordneten Tags – dieselbe Liste wie /tags, also dasselbe Recht.
"/drucken/etiketten",
]) {
await page.goto(adresse);
const gelandet = new URL(page.url()).pathname;
expect(gelandet, `${adresse} hat einen Leser nicht abgewiesen.`).not.toBe(adresse);
expect(gelandet, `${adresse} weist ab, ohne zu sagen warum.`).toBe("/kein-zugriff");
await expect(page.getByRole("heading", { name: "Dafür fehlt dir die Berechtigung" })).toBeVisible();
// Und die Auskunft nennt die eigene Rolle, damit klar ist, woran es liegt.
// Im Absatz gesucht: „Leser“ steht auch in der Navigation und im Namen des
// Testkontos, und ein `getByText` ohne Rahmen trifft im Modus
// DIENSTLEISTUNG sechs Stellen.
await expect(page.getByRole("main").getByText("mit der Rolle")).toContainText("Leser");
}
});
test("darf lesen, was zum Lesen da ist", async ({ page }) => {
/*
* Die Gegenprobe, und sie gehört dazu: würde hier etwas abgewiesen, wäre die
* Prüfung oben nicht der Nachweis eines Rechts, sondern der eines kaputten
* Kontos. Der Zustands-Endpunkt steht mit dabei, weil ein Route Handler die
* Hülle nicht erbt und sein Recht selbst prüfen muss (CLAUDE.md, „Rechte“).
*/
await alsLeserAnmelden(page);
for (const adresse of ["/", "/laeufe", "/vorlagen", "/mitarbeiter", "/geraete", "/archiv", "/scannen"]) {
const antwort = await page.goto(adresse);
expect(antwort?.status(), `${adresse} antwortet einem Leser nicht mit 200.`).toBe(200);
expect(new URL(page.url()).pathname, `${adresse} hat einen Leser weitergeleitet.`).toBe(adresse);
}
const zustand = await page.request.get(`/api/laeufe/${laufId}/zustand`);
expect(zustand.status()).toBe(200);
// Kein Befehl in der Antwort: sonst stünde dort ein Passwort, das die Seite
// bewusst nicht von selbst neu baut.
expect(await zustand.text()).not.toContain("Rename-Computer");
});
});
test.afterAll(async () => {
if (laufId) {
await prisma.kommentar.deleteMany({ where: { laufId } });
await prisma.schrittergebnis.deleteMany({ where: { laufId } });
await prisma.protokoll.deleteMany({ where: { OR: [{ laufId }, { entitaetId: laufId }] } });
await prisma.lauf.deleteMany({ where: { id: laufId } });
}
if (leserId) {
/*
* `Protokoll.benutzerId` steht auf `Restrict`. Ein Leser schreibt nichts, was
* protokolliert wird — aber „sollte nichts geschrieben haben“ ist kein Grund,
* das Aufräumen an einem Fremdschlüssel scheitern zu lassen.
*/
await prisma.protokoll.deleteMany({ where: { benutzerId: leserId } });
await prisma.benutzer.deleteMany({ where: { id: leserId } });
}
await prisma.$disconnect();
});
+8 -13
View File
@@ -1,5 +1,6 @@
import { expect, test, type Page } from "@playwright/test"; import { expect, test } from "@playwright/test";
import "dotenv/config"; import "dotenv/config";
import { anmelden, SEED_TAG } from "./helfer";
/** /**
* Der Einstieg ueber den Aufkleber (#014). * Der Einstieg ueber den Aufkleber (#014).
@@ -11,18 +12,12 @@ import "dotenv/config";
* bringt den Techniker genauso ans Ziel wie der gescannte. Das ist kein * bringt den Techniker genauso ans Ziel wie der gescannte. Das ist kein
* Ersatzweg fuer den Notfall, sondern der Weg, den jeder geht, dessen Browser * Ersatzweg fuer den Notfall, sondern der Weg, den jeder geht, dessen Browser
* keine Kamera freigibt. * keine Kamera freigibt.
*
* Der Code kommt aus `SEED_TAG` und nicht abgetippt aus dem Kopf: dass es ihn in
* dieser Datenbank gibt, prueft `tests/e2e/global-setup.ts` vor dem ersten
* Ablauf (#018).
*/ */
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"]')]);
}
test.describe("Scannen", () => { test.describe("Scannen", () => {
test("ist über den Knopf in der Hülle erreichbar", async ({ page }) => { test("ist über den Knopf in der Hülle erreichbar", async ({ page }) => {
await anmelden(page); await anmelden(page);
@@ -36,10 +31,10 @@ test.describe("Scannen", () => {
await page.goto("/scannen"); await page.goto("/scannen");
// Ohne Kamera bleibt das Feld – und es nimmt auch Kleinschreibung an. // Ohne Kamera bleibt das Feld – und es nimmt auch Kleinschreibung an.
await page.getByLabel("Code eintippen").fill("lz-7k3f9q"); await page.getByLabel("Code eintippen").fill(SEED_TAG.toLowerCase());
await page.getByRole("button", { name: "Öffnen" }).click(); await page.getByRole("button", { name: "Öffnen" }).click();
await page.waitForURL("**/t/LZ-7K3F9Q"); await page.waitForURL(`**/t/${SEED_TAG}`);
}); });
test("lässt einen unsinnigen Code gar nicht erst abschicken", async ({ page }) => { test("lässt einen unsinnigen Code gar nicht erst abschicken", async ({ page }) => {

Some files were not shown because too many files have changed in this diff Show More