feat(doku): Anleitung, PDF und Ende-zu-Ende-Abnahme (#36)
@@ -22,6 +22,12 @@ blob-report/
|
||||
.env.*
|
||||
!.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
|
||||
*.tsbuildinfo
|
||||
.DS_Store
|
||||
|
||||
@@ -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
|
||||
Maria Huber genau `m.huber@firma.example` und `mhuber` vor — dieselben Werte, die im
|
||||
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
|
||||
keinem Platzhalter gelesen; `Move-ADObject -TargetPath "{{ou_gesperrt}}"` blieb leer, obwohl die
|
||||
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
|
||||
|
||||
@@ -301,3 +314,7 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||
Passwortmanager legte, sperrte den neuen Mitarbeiter aus (#017)
|
||||
- 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)
|
||||
- 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)
|
||||
|
||||
@@ -177,13 +177,34 @@ einlesen.
|
||||
|
||||
## Umgebungsvariablen
|
||||
|
||||
Alle Variablen mit Erklärung stehen in [`.env.example`](.env.example). Pflicht sind `APP_URL`,
|
||||
`APP_SECRET`, `POSTGRES_PASSWORD` und `DATABASE_URL`; der Compose-Stand bricht ab, statt mit
|
||||
Standardwerten zu starten.
|
||||
Jede Variable steht mit Erklärung in [`.env.example`](.env.example) — hier die Übersicht. **Pflicht**
|
||||
sind `APP_URL`, `APP_SECRET`, `POSTGRES_PASSWORD` und `DATABASE_URL`; fehlt eine davon, bricht der
|
||||
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
|
||||
|
||||
- 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)
|
||||
- [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
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
@@ -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
|
||||
|
||||

|
||||
|
||||
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 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
|
||||
|
||||

|
||||
|
||||
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 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.
|
||||
|
||||

|
||||
|
||||
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** 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.
|
||||
|
||||

|
||||
|
||||
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).
|
||||
@@ -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**“).
|
||||
|
||||

|
||||
|
||||
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 |
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
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?
|
||||
|
||||

|
||||
|
||||
- **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.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
@@ -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
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
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.
|
||||
@@ -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
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
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.**
|
||||
|
||||

|
||||
|
||||
### 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
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
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.
|
||||
@@ -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
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
**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
|
||||
|
||||

|
||||
|
||||
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).
|
||||
@@ -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
|
||||
|
||||

|
||||
|
||||
**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
|
||||
|
||||

|
||||
|
||||
### 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
|
||||
|
||||

|
||||
|
||||
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.**
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
After Width: | Height: | Size: 394 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 376 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 376 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 282 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 303 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 346 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 800 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 228 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 368 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 435 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 605 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 331 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 216 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 438 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 368 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 510 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 485 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 254 KiB |
|
After Width: | Height: | Size: 118 KiB |
@@ -20,11 +20,12 @@ als Unterpunkte mit Aufgabe und Dauer.
|
||||
| #015 | M10 – Dashboard und Archiv | 2 h 20 min |
|
||||
| #016 | M11 – Verzeichnis und LDAP-Abgleich | 3 h 25 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 |
|
||||
|---|---|---|
|
||||
| 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)
|
||||
- #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.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# #018 M13 – E2E-Tests, Anleitung, PDF
|
||||
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.1.0
|
||||
**Labels:** phase-1, test, docs
|
||||
**Geschätzt:** 14 h **Tatsächlich:** –
|
||||
**Geschätzt:** 14 h **Tatsächlich:** 2 h 15 min
|
||||
|
||||
## Ziel
|
||||
|
||||
@@ -11,27 +11,75 @@ Nachweis, dass die Abläufe wirklich durchlaufen, und eine Anleitung, mit der ei
|
||||
|
||||
## 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
|
||||
- [ ] 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
|
||||
- [ ] 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`
|
||||
- [ ] 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
|
||||
- [ ] 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)
|
||||
- [ ] 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: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/`
|
||||
- [ ] `pnpm docs:pdf`: Anleitung als PDF über Chromium (`page.pdf`, A4, `printBackground`), Ablage `docs/anleitung/laufzettel-anleitung.pdf`
|
||||
- [ ] `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
|
||||
- [ ] `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] 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] Derselbe Hauptablauf mit `SEED_MODE=SERVICE` gegen `MUS-PC-001`, mit Kundenwahl im Start-Dialog
|
||||
- [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
|
||||
- [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)
|
||||
- [x] Playwright-Mobilprüfung: auf allen Hauptseiten bei 390 × 844 ist `scrollWidth <= clientWidth`
|
||||
- [x] Playwright-Rollenprüfung: `LESER` sieht keine Schreib-Knöpfe und wird serverseitig abgewiesen
|
||||
- [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
|
||||
- [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)
|
||||
- [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)
|
||||
- [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
|
||||
- [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)
|
||||
- [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/`
|
||||
- [x] `pnpm docs:pdf`: Anleitung als PDF über Chromium (`page.pdf`, A4, `printBackground`), Ablage `docs/anleitung/laufzettel-anleitung.pdf`
|
||||
- [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
|
||||
|
||||
- [ ] Alle Playwright-Abläufe grün, in `INTERN` und in `DIENSTLEISTUNG`
|
||||
- [ ] Der Kopier-Knopf liefert exakt den erwarteten Befehlstext, zeichengenau geprüft
|
||||
- [ ] Keine Hauptseite scrollt bei 390 px horizontal
|
||||
- [ ] `pnpm docs:screenshots` erzeugt alle in der Anleitung verlinkten Bilder reproduzierbar neu
|
||||
- [ ] `pnpm pruefe:doku` meldet kein fehlendes Bild
|
||||
- [ ] 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] Alle Playwright-Abläufe grün, in `INTERN` und in `DIENSTLEISTUNG`
|
||||
- [x] Der Kopier-Knopf liefert exakt den erwarteten Befehlstext, zeichengenau geprüft
|
||||
- [x] Keine Hauptseite scrollt bei 390 px horizontal
|
||||
- [x] `pnpm docs:screenshots` erzeugt alle in der Anleitung verlinkten Bilder reproduzierbar neu
|
||||
- [x] `pnpm pruefe:doku` meldet kein fehlendes Bild
|
||||
- [x] Das PDF enthält alle Kapitel mit Bildern, Umlaute korrekt, keine abgeschnittenen Seiten
|
||||
- [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).
|
||||
|
||||
@@ -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
|
||||
- [ ] `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
|
||||
|
||||
- [ ] `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`
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 |
|
||||
| #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 |
|
||||
| #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 | – |
|
||||
| #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 | – |
|
||||
| | **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** |
|
||||
|
||||
@@ -4,6 +4,13 @@ const APP_URL = process.env["E2E_APP_URL"] ?? "http://127.0.0.1:3000";
|
||||
|
||||
export default defineConfig({
|
||||
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.
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
@@ -20,6 +27,16 @@ export default defineConfig({
|
||||
},
|
||||
projects: [
|
||||
{ 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 } },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.");
|
||||
@@ -54,6 +54,20 @@ type Seite = {
|
||||
* wie nebenan, nur groesser.
|
||||
*/
|
||||
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. */
|
||||
@@ -137,7 +151,16 @@ const SEITEN: readonly Seite[] = [
|
||||
},
|
||||
{ name: "geraete-liste", pfad: "/geraete" },
|
||||
{ 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",
|
||||
pfad: "/laeufe",
|
||||
@@ -148,6 +171,24 @@ const SEITEN: readonly Seite[] = [
|
||||
.click();
|
||||
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
|
||||
@@ -176,6 +217,15 @@ const SEITEN: readonly Seite[] = [
|
||||
await tab.getByRole("link", { name: "NB-001" }).first().click();
|
||||
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 nur = argument("nur");
|
||||
/** Welcher Betriebsmodus gerade eingestellt ist – steuert, welche Seiten passen. */
|
||||
@@ -376,6 +484,7 @@ try {
|
||||
// Schriften muessen geladen sein, sonst wackelt das Bild zwischen Laeufen.
|
||||
await tab.evaluate(() => document.fonts.ready);
|
||||
await tab.waitForLoadState("domcontentloaded");
|
||||
if (seite.markierungen) await markierungenEinspritzen(tab, seite.name, seite.markierungen);
|
||||
|
||||
const datei = join(ziel, `${seite.name}-${ansicht.name}.png`);
|
||||
if (seite.ausschnitt) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -22,7 +22,6 @@ const MELDUNGEN = new Map<string, string>(
|
||||
"oidc-aus": "Die Anmeldung über den Anbieter ist nicht eingerichtet.",
|
||||
"oidc-start": "Der Anbieter war nicht erreichbar. Bitte später erneut versuchen.",
|
||||
"oidc-rueckruf": "Die Anmeldung beim Anbieter ist fehlgeschlagen.",
|
||||
keinrecht: "Für diesen Bereich fehlt dir die Berechtigung.",
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -87,11 +87,18 @@ export async function sitzungErforderlich(recht?: Recht, ziel?: string): Promise
|
||||
redirect(ziel ? `/anmelden?ziel=${encodeURIComponent(ziel)}` : "/anmelden");
|
||||
}
|
||||
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.
|
||||
// Deshalb hier eine Weiterleitung mit Erklaerung; in Server Actions bleibt
|
||||
// das Werfen richtig, siehe rechtErforderlich.
|
||||
redirect("/anmelden?fehler=keinrecht");
|
||||
/*
|
||||
* Bei einer Seite ist ein geworfener Fehler eine 500er-Seite – fuer jemanden,
|
||||
* der einfach ein altes Lesezeichen geoeffnet hat, die falsche Auskunft.
|
||||
* Deshalb eine Weiterleitung mit Erklaerung; in Server Actions bleibt das
|
||||
* 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;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import "dotenv/config";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { anmelden, SEED_VORLAGE } from "./helfer";
|
||||
|
||||
/**
|
||||
* 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
|
||||
* eintippen, Treffer sehen, **erkennen warum**, in den Lauf springen. Ein
|
||||
* 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 NOTIZ = `Restposten ${STEMPEL} aus dem Lager`;
|
||||
|
||||
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"]')]);
|
||||
}
|
||||
/** Fortlaufend, damit zwei Laeufe desselben Durchgangs sich nicht verwechseln. */
|
||||
let laufende = 0;
|
||||
|
||||
type Testlauf = { readonly id: string; readonly titel: string; readonly suchwort: string };
|
||||
|
||||
/**
|
||||
* 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`
|
||||
* vom Anlegen bis zum Archivieren. Die Zeitstempel gehoeren trotzdem dazu, drei
|
||||
* 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({
|
||||
where: { status: "VEROEFFENTLICHT" },
|
||||
orderBy: { veroeffentlichtAm: "desc" },
|
||||
where: { status: "VEROEFFENTLICHT", vorlage: { name: SEED_VORLAGE } },
|
||||
orderBy: { version: "desc" },
|
||||
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;
|
||||
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 titel = `ZZ E2E Archiv ${STEMPEL}`;
|
||||
const titel = `ZZ E2E Archiv ${marke}`;
|
||||
const lauf = await prisma.lauf.create({
|
||||
data: {
|
||||
vorlagenVersionId: version.id,
|
||||
@@ -60,10 +71,10 @@ async function archivierterLauf(): Promise<{ id: string; titel: string }> {
|
||||
laufId: lauf.id,
|
||||
vorlagenSchrittId: schrittId,
|
||||
zustand: "NICHT_ZUTREFFEND",
|
||||
notiz: NOTIZ,
|
||||
notiz: `${suchwort} aus dem Lager`,
|
||||
},
|
||||
});
|
||||
return { id: lauf.id, titel };
|
||||
return { id: lauf.id, titel, suchwort };
|
||||
}
|
||||
|
||||
test.describe("Archiv", () => {
|
||||
@@ -77,7 +88,7 @@ test.describe("Archiv", () => {
|
||||
}
|
||||
await page.getByRole("link", { name: "Archiv" }).first().click();
|
||||
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();
|
||||
|
||||
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.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 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(() => ({
|
||||
scroll: document.documentElement.scrollWidth,
|
||||
sichtbar: document.documentElement.clientWidth,
|
||||
@@ -114,6 +133,7 @@ test.describe("Archiv", () => {
|
||||
|
||||
test.afterAll(async () => {
|
||||
const laeufe = await prisma.lauf.findMany({
|
||||
// Der Praefix gehoert dieser Datei allein – geraeumt wird nur, was sie anlegt.
|
||||
where: { titel: { startsWith: "ZZ E2E Archiv" } },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import "dotenv/config";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { anmelden, SEED_TAG } from "./helfer";
|
||||
|
||||
/**
|
||||
* 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 =
|
||||
* 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.
|
||||
*
|
||||
* 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 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("druckt ein Einzeletikett auf 62 × 29 mm", async ({ page }) => {
|
||||
test.skip(test.info().project.name !== "desktop", "Das Seitenformat hängt nicht am Gerät.");
|
||||
await anmelden(page);
|
||||
const code = await einTagCode();
|
||||
await page.goto(`/drucken/etiketten?layout=etikett&codes=${code}`);
|
||||
await page.goto(`/drucken/etiketten?layout=etikett&codes=${SEED_TAG}`);
|
||||
|
||||
/*
|
||||
* `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.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
|
||||
@@ -84,7 +70,7 @@ test.describe("Etikettendruck", () => {
|
||||
try {
|
||||
const tab = await kontext.newPage();
|
||||
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();
|
||||
await expect(etikett).toBeVisible();
|
||||
const bild = await etikett.screenshot();
|
||||
@@ -100,7 +86,7 @@ test.describe("Etikettendruck", () => {
|
||||
* dazulegen koennte.
|
||||
*/
|
||||
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.hash).toBe("");
|
||||
} finally {
|
||||
@@ -157,7 +143,3 @@ function seitenmasseAusPdf(pdf: Buffer): { breite: number; hoehe: number } {
|
||||
hoehe: Number(treffer[4]) - Number(treffer[2]),
|
||||
};
|
||||
}
|
||||
|
||||
test.afterAll(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import "dotenv/config";
|
||||
import { anmelden, kundeWaehlen } from "./helfer";
|
||||
|
||||
/**
|
||||
* Die Geraeteliste durch die Oberflaeche (#011).
|
||||
@@ -11,8 +12,10 @@ import "dotenv/config";
|
||||
* einlesen, loeschen — und anschliessend, ob es wirklich in der Datenbank steht:
|
||||
* durch Neuladen.
|
||||
*
|
||||
* Voraussetzung: ein **gebauter** Stand laeuft (`pnpm build && pnpm start`) und
|
||||
* `SEED_ADMIN_EMAIL`/`SEED_ADMIN_PASSWORD` sind gesetzt.
|
||||
* Voraussetzungen — ein gebauter Stand antwortet, der Seed-Bestand steht und der
|
||||
* 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,
|
||||
* 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()}`;
|
||||
|
||||
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. */
|
||||
function anlegenFormular(seite: Page) {
|
||||
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("Seriennummer").fill(`${MARKE}-SN`);
|
||||
await formular.getByLabel("Typ").selectOption("NOTEBOOK");
|
||||
await kundeWaehlen(formular);
|
||||
await formular.getByRole("button", { name: "Gerät anlegen" }).click();
|
||||
|
||||
// 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);
|
||||
await formular.getByLabel("Hostname").fill(name);
|
||||
await kundeWaehlen(formular);
|
||||
await formular.getByRole("button", { name: "Gerät anlegen" }).click();
|
||||
await expect(formular.getByRole("status")).toContainText("wurde angelegt");
|
||||
|
||||
@@ -150,6 +145,7 @@ test.describe("Geräte", () => {
|
||||
`${MARKE}-CSV-BOESE;;SN-BOESE;INV-2;Tablet;`,
|
||||
].join("\r\n"),
|
||||
);
|
||||
await kundeWaehlen(bereich);
|
||||
await bereich.getByRole("button", { name: "Einlesen" }).click();
|
||||
|
||||
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);
|
||||
await formular.getByLabel("Bezeichnung").fill(name);
|
||||
await formular.getByLabel("Typ").selectOption("RAUM");
|
||||
await kundeWaehlen(formular);
|
||||
await formular.getByRole("button", { name: "Gerät anlegen" }).click();
|
||||
await expect(formular.getByRole("status")).toContainText("wurde angelegt");
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import "dotenv/config";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { anmelden, SEED_TAG } from "./helfer";
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Teile zusammenkommen, die einzeln schon funktionierten — die Weiche unter
|
||||
* `/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();
|
||||
|
||||
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"]')]);
|
||||
}
|
||||
/** Praefix der Geraete, die dieser Ablauf anlegt – und nur dieser. */
|
||||
const HOSTNAME_PRAEFIX = "ZZ-E2E-TAG-";
|
||||
|
||||
/**
|
||||
* Eine schlichte Vorlage **ohne** Pflichtangaben.
|
||||
@@ -59,7 +58,18 @@ async function tagAnlegen(): Promise<string> {
|
||||
where: { rolle: "ADMIN" },
|
||||
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];
|
||||
if (!code) throw new Error("Der Stapel kam leer zurück.");
|
||||
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
|
||||
* sucht er das Gerät noch einmal, das er gerade in der Hand hält.
|
||||
*/
|
||||
const geraet = await prisma.geraet.findFirstOrThrow({
|
||||
where: { hostname: "PC-001" },
|
||||
select: { id: true, tagCode: true },
|
||||
const geraet = await prisma.geraet.findUniqueOrThrow({
|
||||
where: { tagCode: SEED_TAG },
|
||||
select: { id: true, hostname: true },
|
||||
});
|
||||
|
||||
await page.context().clearCookies();
|
||||
await page.goto(`/t/${geraet.tagCode}`);
|
||||
await page.goto(`/t/${SEED_TAG}`);
|
||||
await page.waitForURL(/\/anmelden\?ziel=/);
|
||||
|
||||
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.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 }) => {
|
||||
@@ -111,7 +122,7 @@ test.describe("Vom Aufkleber zur Arbeit", () => {
|
||||
await page.goto(`/t/${code.toLowerCase()}`);
|
||||
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("Typ").selectOption("NOTEBOOK");
|
||||
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.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 page.goto("/geraete");
|
||||
await page.getByRole("link", { name: "NB-001" }).first().click();
|
||||
await page.goto(`/geraete?suche=${SEED_TAG}`);
|
||||
await page.locator("ul > li").filter({ hasText: SEED_TAG }).getByRole("link").first().click();
|
||||
await page.waitForURL(/\/geraete\/[^/]+$/);
|
||||
|
||||
const masse = await page.evaluate(() => ({
|
||||
@@ -186,7 +203,12 @@ test.afterAll(async () => {
|
||||
* Entwicklungsdatenbank ist das derselbe Bestand.
|
||||
*/
|
||||
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 },
|
||||
});
|
||||
const kennungen = geraete.map((geraet) => geraet.id);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,6 +1,16 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import "dotenv/config";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
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).
|
||||
@@ -10,68 +20,57 @@ import { prisma } from "@/lib/db";
|
||||
* was nur mit angehaengtem React gelingt, und danach, ob es wirklich in der
|
||||
* Datenbank steht: durch Neuladen.
|
||||
*
|
||||
* Voraussetzung: ein **gebauter** Stand laeuft (`pnpm build && pnpm start`) und
|
||||
* `pnpm beispiel:vorlage` hat die Beispielvorlage angelegt.
|
||||
* Voraussetzungen prueft `tests/e2e/global-setup.ts` vor dem ersten Ablauf: ein
|
||||
* **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 TITEL = `ZZ E2E Lauf ${STEMPEL}`;
|
||||
|
||||
async function anmelden(seite: Page) {
|
||||
const email = process.env["SEED_ADMIN_EMAIL"];
|
||||
const passwort = process.env["SEED_ADMIN_PASSWORD"];
|
||||
if (!email || !passwort) throw new Error("SEED_ADMIN_EMAIL und SEED_ADMIN_PASSWORD fehlen.");
|
||||
await seite.goto("/anmelden");
|
||||
await seite.fill('input[name="email"]', email);
|
||||
await seite.fill('input[name="passwort"]', passwort);
|
||||
await Promise.all([seite.waitForURL("**/"), seite.click('button[type="submit"]')]);
|
||||
/**
|
||||
* Das Beispielgeraet, geholt ueber den **Tag-Code**.
|
||||
*
|
||||
* Nicht ueber `{ index: 1 }` und nicht ueber den Hostnamen: die Position in der
|
||||
* Liste haengt an der Sortierung, und „PC-001“ gibt es in einer benutzten
|
||||
* Datenbank mehr als einmal. Der Tag-Code gehoert genau einem Geraet — und dass
|
||||
* es dieses gibt, prueft der Global-Setup.
|
||||
*/
|
||||
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. */
|
||||
async function laufStarten(seite: Page, titel: string): Promise<string> {
|
||||
await seite.goto("/laeufe/starten");
|
||||
|
||||
await vorlageWaehlen(seite, SEED_VORLAGE);
|
||||
/*
|
||||
* Die Vorlage wird **am Namen** gewaehlt und nicht ueber `index: 1`. Seit dem
|
||||
* Seed der sieben Vorlagen (#017) steht dort an erster Stelle, was die
|
||||
* Sortierung gerade nach vorn stellt — bei einer frisch eingerichteten
|
||||
* Installation etwa „Mitarbeiter-Offboarding“ mit Pflichtangaben, die dieser
|
||||
* Helfer nicht kennt. Der Ablauf lief dann in den Zeitablauf, und die Meldung
|
||||
* sagte nichts ueber die Ursache.
|
||||
* Erst der Kunde, dann das Geraet — und zwar **der Kunde des Geraets**. Im
|
||||
* Modus DIENSTLEISTUNG fragt der Dialog danach, und ein Lauf kann strukturell
|
||||
* kein Geraet eines fremden Kunden waehlen: der zusammengesetzte
|
||||
* Fremdschluessel `Lauf_geraet_gleicher_kunde` weist das ab. „Irgendein Kunde“
|
||||
* waere hier also nicht ungenau, sondern rot. Im Modus INTERN gibt es das Feld
|
||||
* nicht.
|
||||
*/
|
||||
const vorlagen = await seite
|
||||
.locator('select[name="vorlagenVersionId"] option')
|
||||
.evaluateAll((liste) =>
|
||||
liste
|
||||
.filter((option): option is HTMLOptionElement => option instanceof HTMLOptionElement)
|
||||
.map((option) => ({ wert: option.value, text: option.textContent })),
|
||||
);
|
||||
const beispiel = vorlagen.find((eintrag) => (eintrag.text ?? "").includes(BEISPIEL_VORLAGE));
|
||||
if (!beispiel) {
|
||||
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 });
|
||||
const geraetId = await seedGeraetId();
|
||||
const geraet = await prisma.geraet.findUniqueOrThrow({
|
||||
where: { id: geraetId },
|
||||
select: { kundeId: true },
|
||||
});
|
||||
await kundeWaehlen(seite, geraet.kundeId);
|
||||
// Das Geraet setzt `{{hostname}}` im Befehlsschritt – ohne es bliebe der
|
||||
// Platzhalter stehen, und der Test darauf pruefte nichts.
|
||||
await seite.selectOption('select[name="geraetId"]', geraetId);
|
||||
await seite.locator("details summary", { hasText: "Titel, Ticket" }).click();
|
||||
await seite.getByLabel("Titel", { exact: true }).fill(titel);
|
||||
|
||||
// Pflichtvariablen der Vorlage fuellen – welche es sind, weiss nur die Vorlage.
|
||||
for (const feld of await seite.locator("fieldset select").all()) {
|
||||
await feld.selectOption({ index: 1 });
|
||||
}
|
||||
for (const feld of await seite.locator('fieldset input[type="number"]').all()) {
|
||||
await feld.fill("16");
|
||||
}
|
||||
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 variablenFuellen(seite);
|
||||
|
||||
await seite.getByRole("button", { name: "Lauf anlegen" }).click();
|
||||
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 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.
|
||||
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" });
|
||||
await expect(speichern).toBeVisible();
|
||||
await expect(speichern).toBeDisabled();
|
||||
@@ -211,15 +213,26 @@ test.describe("Ein Lauf von vorn bis hinten", () => {
|
||||
await laufStarten(page, `${TITEL} C`);
|
||||
|
||||
/*
|
||||
* Der Befehl **am Schritt**, nicht die Skript-Kopie: die steckt in einem
|
||||
* zugeklappten `details` und ist deshalb unsichtbar, bis jemand sie
|
||||
* aufklappt.
|
||||
* Am **Schritt**, der den Platzhalter traegt, und nicht am ersten `pre.mono`
|
||||
* der Seite: nur dort beweist „enthaelt `{{hostname}}` nicht“ etwas. Ein
|
||||
* 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).not.toContainText("{{hostname}}");
|
||||
await expect(page.getByRole("button", { name: "Befehl kopieren" }).first()).toBeVisible();
|
||||
await expect(page.getByText("Alle Befehle als Skript kopieren")).toBeVisible();
|
||||
// Und was stattdessen dasteht: der Name des gewaehlten Geraets.
|
||||
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 }) => {
|
||||
@@ -285,7 +298,23 @@ test.describe("Namensvorschläge im Start-Dialog", () => {
|
||||
*/
|
||||
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 () => {
|
||||
const vorher = await prisma.einstellung.findUniqueOrThrow({
|
||||
where: { id: "global" },
|
||||
select: { namensregeln: true },
|
||||
});
|
||||
namensregelnVorher = vorher.namensregeln;
|
||||
await prisma.einstellung.update({
|
||||
where: { id: "global" },
|
||||
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.slow();
|
||||
await anmelden(page);
|
||||
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");
|
||||
/*
|
||||
* 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();
|
||||
|
||||
@@ -351,8 +378,9 @@ test.describe("Namensvorschläge im Start-Dialog", () => {
|
||||
test.slow();
|
||||
await anmelden(page);
|
||||
await page.goto("/laeufe/starten");
|
||||
await vorlageWaehlen(page);
|
||||
await vorlageWaehlen(page, VORLAGE);
|
||||
await page.getByLabel("… oder Name als Freitext").fill("Maria Huber");
|
||||
await kundeWaehlen(page);
|
||||
await page.getByLabel("upn").fill("chefin@firma.de");
|
||||
|
||||
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 },
|
||||
});
|
||||
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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import "dotenv/config";
|
||||
import { anmelden, SEED_TAG } from "./helfer";
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Ersatzweg fuer den Notfall, sondern der Weg, den jeder geht, dessen Browser
|
||||
* 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("ist über den Knopf in der Hülle erreichbar", async ({ page }) => {
|
||||
await anmelden(page);
|
||||
@@ -36,10 +31,10 @@ test.describe("Scannen", () => {
|
||||
await page.goto("/scannen");
|
||||
|
||||
// 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.waitForURL("**/t/LZ-7K3F9Q");
|
||||
await page.waitForURL(`**/t/${SEED_TAG}`);
|
||||
});
|
||||
|
||||
test("lässt einen unsinnigen Code gar nicht erst abschicken", async ({ page }) => {
|
||||
|
||||