feat(doku): Anleitung, PDF und Ende-zu-Ende-Abnahme
Neun Kapitel unter docs/anleitung/ fuer Technik und Verwaltung, mit 35 Bildern aus `pnpm docs:screenshots` und einem Fehlerbehebungskapitel aus echten Meldungen. `pnpm docs:pdf` macht daraus 72 Seiten A4 — hell gedruckt, mit Deckblatt, Inhaltsverzeichnis und Seitenzahlen, ohne neue Abhaengigkeit. Die Ablaeufe pruefen ihre Voraussetzungen, bevor der erste startet: antwortet ein gebauter Stand, steht der Seed-Bestand, und liest der Server dieselbe Datenbank wie der Testprozess. Jeder Ablauf legt an, was er braucht, und raeumt es weg. Vier neue kommen dazu: Hauptablauf, Onboarding mit Vergleich der Zwischenablage, Rollen und 390 px auf achtzehn Seiten. Drei Fehler kamen dabei heraus. Das Projekt „handy" pruefte 412 px statt der verlangten 390. Ein angemeldeter Benutzer ohne Recht erfuhr nie, warum er abgewiesen wurde — die Meldung stand auf einer Seite, die ihn sofort weiterleitet; jetzt gibt es /kein-zugriff. Und der Verzeichnis-Ablauf schrieb die Attributzuordnung der ersten vorhandenen Quelle um, in einer echten Installation also der produktiven. 101 Ablaeufe in INTERN und 97 in DIENSTLEISTUNG, beide ohne Fehlschlag. Closes #18 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@@ -22,6 +22,12 @@ blob-report/
|
|||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
|
# Die Anleitung als PDF entsteht aus den Markdown-Kapiteln und den Bildern, die
|
||||||
|
# beide im Repo liegen (`pnpm docs:pdf`). Ein Erzeugnis von 8,7 MB, das sich bei
|
||||||
|
# jedem Lauf vollstaendig aendert, gehoert nicht in die Versionsgeschichte — dem
|
||||||
|
# Release liegt es bei, so steht es im Auftrag.
|
||||||
|
/docs/anleitung/*.pdf
|
||||||
|
|
||||||
# Werkzeuge
|
# Werkzeuge
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -171,9 +171,22 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
- Namensregeln und die Sperr-OU kommen mit dem Seed: „Namen aus den Regeln vorschlagen" schlägt für
|
- Namensregeln und die Sperr-OU kommen mit dem Seed: „Namen aus den Regeln vorschlagen" schlägt für
|
||||||
Maria Huber genau `m.huber@firma.example` und `mhuber` vor — dieselben Werte, die im
|
Maria Huber genau `m.huber@firma.example` und `mhuber` vor — dieselben Werte, die im
|
||||||
Beispielbestand stehen (#017)
|
Beispielbestand stehen (#017)
|
||||||
|
- Anleitung für Technik und Verwaltung unter `docs/anleitung/`: neun Kapitel von den ersten Schritten
|
||||||
|
bis zur Fehlerbehebung, mit Bildern, die `pnpm docs:screenshots` reproduzierbar neu erzeugt (#018)
|
||||||
|
- `pnpm docs:pdf` macht daraus ein A4-PDF mit Deckblatt, Inhaltsverzeichnis und Seitenzahlen — hell
|
||||||
|
gedruckt, auch wenn die App dunkel ist (#018)
|
||||||
|
- Screenshots lassen sich markieren: Rahmen in der Akzentfarbe mit Nummer, für die Stellen, die ein
|
||||||
|
Kapitel gerade beschreibt (#018)
|
||||||
- `{{ou_gesperrt}}` wird aus Admin → Einstellungen vorbelegt. Das Feld war gespeichert und wurde von
|
- `{{ou_gesperrt}}` wird aus Admin → Einstellungen vorbelegt. Das Feld war gespeichert und wurde von
|
||||||
keinem Platzhalter gelesen; `Move-ADObject -TargetPath "{{ou_gesperrt}}"` blieb leer, obwohl die
|
keinem Platzhalter gelesen; `Move-ADObject -TargetPath "{{ou_gesperrt}}"` blieb leer, obwohl die
|
||||||
Einstellungsseite es versprach (#017)
|
Einstellungsseite es versprach (#017)
|
||||||
|
- Die Ende-zu-Ende-Abläufe prüfen ihre Voraussetzungen, bevor der erste startet: läuft ein gebauter
|
||||||
|
Stand, steht der Seed-Bestand, und liest der Server **dieselbe** Datenbank wie der Testprozess.
|
||||||
|
Vorher fiel eine fehlende Voraussetzung erst mitten im Lauf auf — als Zeitablauf oder als „0
|
||||||
|
Treffer" (#018)
|
||||||
|
- Jeder Ablauf legt an, was er braucht, und räumt es weg. Keiner setzt mehr voraus, dass die
|
||||||
|
Datenbank schon Läufe, Entwürfe oder bestimmte Personen enthält, und keiner sucht seine Vorlage
|
||||||
|
über die Position in der Liste (#018)
|
||||||
|
|
||||||
### Sicherheit
|
### Sicherheit
|
||||||
|
|
||||||
@@ -301,3 +314,7 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
Passwortmanager legte, sperrte den neuen Mitarbeiter aus (#017)
|
Passwortmanager legte, sperrte den neuen Mitarbeiter aus (#017)
|
||||||
- Der Seed vergibt keinen verbrannten Tag-Code neu: er fragt das Vergabebuch und nicht nur die
|
- Der Seed vergibt keinen verbrannten Tag-Code neu: er fragt das Vergabebuch und nicht nur die
|
||||||
Geräteliste, wie es der Gerätedienst tut (#017)
|
Geräteliste, wie es der Gerätedienst tut (#017)
|
||||||
|
- Der Verzeichnis-Ablauf legt seine eigene Quelle an, statt die erste vorhandene zu nehmen: er hätte
|
||||||
|
in einer echten Installation die Attributzuordnung der produktiven Quelle überschrieben (#018)
|
||||||
|
- Ein Nachlauf setzte die Namensregeln auf leer, statt den vorherigen Wert wiederherzustellen — er
|
||||||
|
löschte damit, was der Seed eingerichtet hatte (#018)
|
||||||
|
|||||||
@@ -177,13 +177,34 @@ einlesen.
|
|||||||
|
|
||||||
## Umgebungsvariablen
|
## Umgebungsvariablen
|
||||||
|
|
||||||
Alle Variablen mit Erklärung stehen in [`.env.example`](.env.example). Pflicht sind `APP_URL`,
|
Jede Variable steht mit Erklärung in [`.env.example`](.env.example) — hier die Übersicht. **Pflicht**
|
||||||
`APP_SECRET`, `POSTGRES_PASSWORD` und `DATABASE_URL`; der Compose-Stand bricht ab, statt mit
|
sind `APP_URL`, `APP_SECRET`, `POSTGRES_PASSWORD` und `DATABASE_URL`; fehlt eine davon, bricht der
|
||||||
Standardwerten zu starten.
|
Compose-Stand ab, statt mit Standardwerten zu starten.
|
||||||
|
|
||||||
|
| Variable | Bedeutung |
|
||||||
|
|---|---|
|
||||||
|
| `APP_URL` | Die **öffentliche** Adresse, unter der die App erreichbar ist. Steht sie hinter einem Reverse Proxy, ist es dessen Adresse — nicht die interne. Aus ihr entstehen die QR-Adressen auf den Aufklebern und die OIDC-Rücksprungadresse. |
|
||||||
|
| `APP_SECRET` | Schlüssel für Sitzungs-Cookies und die Verschlüsselung des LDAP-Bind-Passworts. Mindestens 32 Zeichen, erzeugen mit `openssl rand -base64 48`. **Ändert man ihn, sind alle Sitzungen ungültig und gespeicherte Bind-Passwörter unlesbar.** |
|
||||||
|
| `DATABASE_URL` | Verbindung zur PostgreSQL-Datenbank. |
|
||||||
|
| `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` | Zugangsdaten des Datenbank-Containers im Compose-Stand. |
|
||||||
|
| `AUTH_LOCAL_ENABLED` | `true` schaltet die lokale Anmeldung mit E-Mail und Passwort frei — Notfallzugang und Entwicklung. Auf `false` verschwindet das Formular **und** der Endpunkt lehnt ab. |
|
||||||
|
| `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` | Der Identitätsanbieter. Discovery über `<issuer>/.well-known/openid-configuration`. |
|
||||||
|
| `OIDC_NAME` | Beschriftung des Anmeldeknopfs, etwa „Firmenanmeldung“. |
|
||||||
|
| `OIDC_SCOPE` | Angefragte Scopes; muss einen enthalten, der `groups` liefert. |
|
||||||
|
| `OIDC_GROUP_ADMIN`, `OIDC_GROUP_EDITOR`, `OIDC_GROUP_TECHNICIAN`, `OIDC_GROUP_VIEWER` | Welche Gruppe welche Rolle bekommt. Die Rolle wird bei **jeder** Anmeldung neu gesetzt. |
|
||||||
|
| `OIDC_CUSTOMER_GROUP_PREFIX` | Nur im Modus `DIENSTLEISTUNG`: Gruppen mit diesem Präfix weisen einen Benutzer einem Kunden zu. |
|
||||||
|
| `BIND_ADDR`, `APP_PORT` | Wohin der Compose-Stand die App bindet. Standard `127.0.0.1:3000` — davor gehört ein Reverse Proxy. |
|
||||||
|
| `APP_VERSION`, `GIT_SHA` | Erscheinen unten links in der Oberfläche. Im Compose-Stand Build-Argumente. |
|
||||||
|
| `SEED_ADMIN_EMAIL`, `SEED_ADMIN_PASSWORD` | Der Administrator, den `pnpm db:seed` anlegt. Das Passwort wird nur als argon2-Hash gespeichert. Fehlt eines, überspringt der Seed ihn. |
|
||||||
|
| `SEED_MODE` | `INTERN` (Standard) oder `DIENSTLEISTUNG` — der Alias `SERVICE` tut dasselbe. Betrifft nur den **ersten** Seed-Lauf; danach steht der Modus in der Datenbank. |
|
||||||
|
| `SEED_ORG_NAME`, `SEED_ORG_KUERZEL` | Name und Kürzel der eigenen Organisation. Das Kürzel steht auf Etiketten und in der Hostname-Regel. |
|
||||||
|
| `ZEITPLAN_AKTIV` | `false` schaltet den Verzeichnis-Abgleich nach Zeitplan ab. |
|
||||||
|
| `TEST_DATABASE_URL` | Datenbank für die Tests. Ohne sie überspringen sich alle Datenbanktests. |
|
||||||
|
|
||||||
## Dokumentation
|
## Dokumentation
|
||||||
|
|
||||||
- Anleitung für Techniker und Admins — entsteht mit #018, inkl. Bildern und PDF
|
- [Anleitung für Technik und Verwaltung](docs/anleitung/) — acht Kapitel mit Bildern. `pnpm docs:pdf`
|
||||||
|
macht daraus ein PDF; jedem Release liegt es bei
|
||||||
- [Architektur und Entscheidungen](docs/architektur.md)
|
- [Architektur und Entscheidungen](docs/architektur.md)
|
||||||
- [Austauschformat für Vorlagen](docs/vorlagen-format.md) — wie eine Vorlage als JSON aussieht
|
- [Austauschformat für Vorlagen](docs/vorlagen-format.md) — wie eine Vorlage als JSON aussieht
|
||||||
- [Anbindung an authentik](docs/betrieb/authentik.md) — Inbetriebnahme, Proxmox und Reverse Proxy folgen mit #019
|
- [Anbindung an authentik](docs/betrieb/authentik.md) — Inbetriebnahme, Proxmox und Reverse Proxy folgen mit #019
|
||||||
|
|||||||
@@ -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 |
|
| #015 | M10 – Dashboard und Archiv | 2 h 20 min |
|
||||||
| #016 | M11 – Verzeichnis und LDAP-Abgleich | 3 h 25 min |
|
| #016 | M11 – Verzeichnis und LDAP-Abgleich | 3 h 25 min |
|
||||||
| #017 | M12 – Seed der sieben Vorlagen | 1 h 30 min |
|
| #017 | M12 – Seed der sieben Vorlagen | 1 h 30 min |
|
||||||
| | **Summe** | **27 h 40 min** |
|
| #018 | M13 – E2E-Tests, Anleitung, PDF | 2 h 15 min |
|
||||||
|
| | **Summe** | **29 h 55 min** |
|
||||||
|
|
||||||
| Meilenstein | Tatsächlich | Status |
|
| Meilenstein | Tatsächlich | Status |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| v0.1.0 | 27 h 40 min | in Arbeit |
|
| v0.1.0 | 29 h 55 min | in Arbeit |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -829,3 +830,72 @@ als Unterpunkte mit Aufgabe und Dauer.
|
|||||||
[#021](issues/021-stabiler-schluessel-vorlagen.md) (stabiler Schlüssel für Seed-Vorlagen)
|
[#021](issues/021-stabiler-schluessel-vorlagen.md) (stabiler Schlüssel für Seed-Vorlagen)
|
||||||
- #018 E2E-Tests, Anleitung und PDF — die Hauptabläufe in beiden Betriebsmodi, die bebilderte
|
- #018 E2E-Tests, Anleitung und PDF — die Hauptabläufe in beiden Betriebsmodi, die bebilderte
|
||||||
Anleitung für Techniker und Admins und der PDF-Export über Chromium.
|
Anleitung für Techniker und Admins und der PDF-Export über Chromium.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-26 · Sitzung 14 · 05:30–10:50
|
||||||
|
|
||||||
|
**Issue:** #018 (M13)
|
||||||
|
**Version:** 0.1.0
|
||||||
|
|
||||||
|
### Erledigt
|
||||||
|
|
||||||
|
- **Vier Teile parallel**: vorhandene Abläufe robust machen, vier neue Abläufe, die Anleitung, der
|
||||||
|
PDF-Export. Das Fundament — gemeinsamer Helfer, Vorabprüfung, Betriebsmodus — kam vorher von mir
|
||||||
|
- **Anleitung** unter `docs/anleitung/`: neun Kapitel, rund 11.600 Wörter, getrennt für Technik und
|
||||||
|
Verwaltung, mit 35 Bildern und einem Fehlerbehebungskapitel aus 18 echten Meldungen
|
||||||
|
- **`pnpm docs:pdf`**: 72 Seiten A4, Deckblatt, Inhaltsverzeichnis, Seitenzahlen, hell gedruckt,
|
||||||
|
ohne neue Abhängigkeit (`react-markdown` über `react-dom/server`)
|
||||||
|
- **Markierungen** in den Screenshots: Rahmen in der Akzentfarbe mit Nummer, für die Stellen, die
|
||||||
|
ein Kapitel gerade beschreibt
|
||||||
|
- **Vier neue Abläufe**: Hauptablauf von vorn bis hinten, Onboarding mit Zwischenablage-Vergleich,
|
||||||
|
Rollen, und 390 px auf achtzehn Seiten
|
||||||
|
- **101 Ende-zu-Ende-Abläufe** grün (vorher 67), Gesamtsuite unverändert grün
|
||||||
|
|
||||||
|
### Entscheidungen
|
||||||
|
|
||||||
|
- **Die Voraussetzungen eines Laufs werden geprüft, nicht behauptet.** `tests/e2e/global-setup.ts`
|
||||||
|
fragt vor dem ersten Ablauf: läuft ein gebauter Stand, steht der Seed-Bestand, und liest der
|
||||||
|
Server **dieselbe** Datenbank wie der Testprozess? Jede Meldung nennt die Ursache und was zu tun
|
||||||
|
ist. Die dritte Frage hat in #017 einen halben Abend gekostet.
|
||||||
|
- **Das PDF kommt nicht ins Repo.** Es entsteht aus Kapiteln und Bildern, die beide versioniert
|
||||||
|
sind, und würde bei jedem Lauf 8,7 MB neu anhängen. Der Auftrag sagt „liegt jedem Release bei“ —
|
||||||
|
genau so wird es gemacht.
|
||||||
|
- **Die `beispiel:*`-Skripte bleiben.** Sie sollten laut ihrem Kopfkommentar mit #017 verschwinden;
|
||||||
|
dort wurden zwei Dinge verwechselt. Der Seed richtet eine **Installation** ein, die Skripte
|
||||||
|
stellen her, was **Screenshots und Abläufe** brauchen: einen offenen Entwurf, einen halb
|
||||||
|
abgearbeiteten Lauf, eine Person aus dem Verzeichnis.
|
||||||
|
|
||||||
|
### Probleme
|
||||||
|
|
||||||
|
- **Das Projekt „handy" prüfte 412 px statt 390.** `devices["Pixel 7"]` ist 412 breit; CLAUDE.md und
|
||||||
|
das Abnahmekriterium nennen 390. Jeder „trägt am Handy ohne horizontales Scrollen"-Test lief damit
|
||||||
|
gegen eine Breite, die niemand verlangt hatte — zweiundzwanzig Pixel, die eine ausbrechende Seite
|
||||||
|
tadellos aussehen lassen. Nach der Korrektur blieb die Suite grün; die Zusage hielt also, sie war
|
||||||
|
nur nie geprüft.
|
||||||
|
- **Eine Auskunft, die niemand je gesehen hat.** Ein angemeldeter Benutzer ohne Recht wurde auf
|
||||||
|
`/anmelden?fehler=keinrecht` geschickt — und die Anmeldeseite schickt einen Angemeldeten sofort
|
||||||
|
weiter auf die Übersicht. Die Meldung „Für diesen Bereich fehlt dir die Berechtigung" war
|
||||||
|
unerreichbar, der Kommentar im Code versprach sie trotzdem. Jetzt gibt es `/kein-zugriff`.
|
||||||
|
- **Zwei Tests, die Schaden anrichteten.** Der Verzeichnis-Ablauf nahm die erste vorhandene Quelle
|
||||||
|
und schrieb ihre Attributzuordnung um — in einer echten Installation die produktive. Und ein
|
||||||
|
Nachlauf setzte die Namensregeln auf leer, statt den vorherigen Wert zurückzustellen: er löschte,
|
||||||
|
was der Seed eingerichtet hatte. Beides stammt aus meinen eigenen Meilensteinen.
|
||||||
|
- **Vier stille Annahmen**: Vorlage über die Position in der Liste, „die Datenbank enthält schon
|
||||||
|
Läufe", `test.skip` bei fehlenden Daten, und Fixtures im Kopfkommentar statt geprüft. Alle vier
|
||||||
|
fielen erst auf, als zum ersten Mal gegen eine **frisch eingerichtete** Installation geprüft
|
||||||
|
wurde.
|
||||||
|
- **Sechs Abläufe kannten die Kundenebene nicht.** Im Modus DIENSTLEISTUNG hat jedes Anlege-Formular
|
||||||
|
ein Feld mehr, und es ist Pflicht: `kundeErmitteln` wirft sonst. Schlimmer als das Feld ist die
|
||||||
|
Regel dahinter — ein Lauf kann strukturell kein Gerät und keine Abteilung eines fremden Kunden
|
||||||
|
wählen, dafür sorgen zusammengesetzte Fremdschlüssel. „Irgendein Kunde" ist dort nicht ungenau,
|
||||||
|
sondern rot. Beim Onboarding war es sogar eine Reihenfolge: die Kundenwahl stand hinter dem
|
||||||
|
Namensvorschlag, und ohne Kunden können die Namensregeln gar nicht greifen.
|
||||||
|
- **Parallele Arbeit an einem Verzeichnis geht schief.** Eine Wegwerf-Probe für den PDF-Bau hat die
|
||||||
|
`README.md` der Anleitung überschrieben, während sie geschrieben wurde. Wiederhergestellt, aber
|
||||||
|
der Ablageort für Proben gehört in das Verzeichnis für Zwischenstände, nicht neben die Arbeit.
|
||||||
|
|
||||||
|
### Nächster Schritt
|
||||||
|
|
||||||
|
- #019 Betriebswege und Release `v0.1.0` — `deploy/install.sh`, der Proxmox-One-Liner, die
|
||||||
|
Betriebsdokumentation und der Tag mit Release-Notes.
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# #018 M13 – E2E-Tests, Anleitung, PDF
|
# #018 M13 – E2E-Tests, Anleitung, PDF
|
||||||
|
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.1.0
|
**Meilenstein:** v0.1.0
|
||||||
**Labels:** phase-1, test, docs
|
**Labels:** phase-1, test, docs
|
||||||
**Geschätzt:** 14 h **Tatsächlich:** –
|
**Geschätzt:** 14 h **Tatsächlich:** 2 h 15 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
|
|
||||||
@@ -11,27 +11,75 @@ Nachweis, dass die Abläufe wirklich durchlaufen, und eine Anleitung, mit der ei
|
|||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
|
|
||||||
- [ ] Playwright-Hauptablauf: anmelden → „PC Neuinstallation“ für `PC-001` starten → Schritte abhaken → Wert eintragen → einen Schritt „nicht zutreffend“ mit Notiz → abschließen → archivieren → im Archiv finden → Schreibschutz greift
|
- [x] Playwright-Hauptablauf: anmelden → „PC Neuinstallation“ für `PC-001` starten → Schritte abhaken → Wert eintragen → einen Schritt „nicht zutreffend“ mit Notiz → abschließen → archivieren → im Archiv finden → Schreibschutz greift
|
||||||
- [ ] Derselbe Hauptablauf mit `SEED_MODE=SERVICE` gegen `MUS-PC-001`, mit Kundenwahl im Start-Dialog
|
- [x] Derselbe Hauptablauf mit `SEED_MODE=SERVICE` gegen `MUS-PC-001`, mit Kundenwahl im Start-Dialog
|
||||||
- [ ] Playwright-QR-Ablauf: `/t/LZ-7K3F9Q` ohne Anmeldung → Login → Geräteseite → „Vorlage zuweisen“ → Lauf erscheint unter „Zugewiesen“ → „Starten“ setzt `IN_ARBEIT`; dazu unregistrierter Tag → Registrierformular → Geräteseite; unbekannter Tag → Hinweis
|
- [x] Playwright-QR-Ablauf: `/t/LZ-7K3F9Q` ohne Anmeldung → Login → Geräteseite → „Vorlage zuweisen“ → Lauf erscheint unter „Zugewiesen“ → „Starten“ setzt `IN_ARBEIT`; dazu unregistrierter Tag → Registrierformular → Geräteseite; unbekannter Tag → Hinweis
|
||||||
- [ ] Playwright-Onboarding-Ablauf: Vorlage starten → Vorname und Nachname eingeben → UPN, `sam` und Mail werden vorgeschlagen → Abteilung „Buchhaltung“ wählen → im Schritt „Konto anlegen“ steht der fertige `New-ADUser`-Befehl → Kopieren liefert exakt diesen Text (Clipboard auslesen)
|
- [x] Playwright-Onboarding-Ablauf: Vorlage starten → Vorname und Nachname eingeben → UPN, `sam` und Mail werden vorgeschlagen → Abteilung „Buchhaltung“ wählen → im Schritt „Konto anlegen“ steht der fertige `New-ADUser`-Befehl → Kopieren liefert exakt diesen Text (Clipboard auslesen)
|
||||||
- [ ] Playwright-Mobilprüfung: auf allen Hauptseiten bei 390 × 844 ist `scrollWidth <= clientWidth`
|
- [x] Playwright-Mobilprüfung: auf allen Hauptseiten bei 390 × 844 ist `scrollWidth <= clientWidth`
|
||||||
- [ ] Playwright-Rollenprüfung: `LESER` sieht keine Schreib-Knöpfe und wird serverseitig abgewiesen
|
- [x] Playwright-Rollenprüfung: `LESER` sieht keine Schreib-Knöpfe und wird serverseitig abgewiesen
|
||||||
- [ ] Die Abläufe suchen ihre Vorlage **am Namen**, nicht über `select ... { index: 1 }`. Aufgefallen in #017: gegen eine frisch geseedete Datenbank steht an erster Stelle „Mitarbeiter-Offboarding“ mit Pflichtvariablen, die der Helfer nicht füllt — sechs Abläufe liefen je 30 s in den Zeitablauf. Ein Ablauf, der von der Sortierung der Vorlagenliste abhängt, prüft nicht, was er zu prüfen vorgibt
|
- [x] Die Abläufe suchen ihre Vorlage **am Namen**, nicht über `select ... { index: 1 }`. Aufgefallen in #017: gegen eine frisch geseedete Datenbank steht an erster Stelle „Mitarbeiter-Offboarding“ mit Pflichtvariablen, die der Helfer nicht füllt — sechs Abläufe liefen je 30 s in den Zeitablauf. Ein Ablauf, der von der Sortierung der Vorlagenliste abhängt, prüft nicht, was er zu prüfen vorgibt
|
||||||
- [ ] Die Voraussetzungen eines E2E-Laufs stehen an einer Stelle und werden geprüft, statt im Kopfkommentar zu stehen (`pnpm beispiel:vorlage`, `pnpm beispiel:stammdaten`, `pnpm beispiel:lauf`) — besser noch: die Abläufe legen an, was sie brauchen. Die drei Skripte tragen auch die Screenshots; was davon bleibt und was in die Abläufe wandert, wird hier entschieden. In den Seed gehört es **nicht**: eine frische Installation soll keine Demo-Läufe und keine offenen Entwürfe mitbringen (in #017 geprüft und so entschieden)
|
- [x] Die Voraussetzungen eines E2E-Laufs stehen an einer Stelle und werden geprüft, statt im Kopfkommentar zu stehen (`pnpm beispiel:vorlage`, `pnpm beispiel:stammdaten`, `pnpm beispiel:lauf`) — besser noch: die Abläufe legen an, was sie brauchen. Die drei Skripte tragen auch die Screenshots; was davon bleibt und was in die Abläufe wandert, wird hier entschieden. In den Seed gehört es **nicht**: eine frische Installation soll keine Demo-Läufe und keine offenen Entwürfe mitbringen (in #017 geprüft und so entschieden)
|
||||||
- [ ] Kein Ablauf setzt voraus, dass die Datenbank schon Läufe enthält. `uebersicht-druck.spec.ts` sucht sich mit `lauf.findFirstOrThrow()` irgendeinen Lauf und „Zuletzt bearbeitet" muss gefüllt sein — gegen eine frisch eingerichtete Installation scheitert beides mit „No record was found", und das sagt nichts über die Ursache (gesehen in #017)
|
- [x] Kein Ablauf setzt voraus, dass die Datenbank schon Läufe enthält. `uebersicht-druck.spec.ts` sucht sich mit `lauf.findFirstOrThrow()` irgendeinen Lauf und „Zuletzt bearbeitet" muss gefüllt sein — gegen eine frisch eingerichtete Installation scheitert beides mit „No record was found", und das sagt nichts über die Ursache (gesehen in #017)
|
||||||
- [ ] Der E2E-Lauf prüft beim Start, dass er **dieselbe** Datenbank benutzt wie der Server unter Test. Die Abläufe legen ihre Daten über `prisma` aus `.env` an, die Adresse des Servers steht in `E2E_APP_URL` — zeigen beide auf verschiedene Datenbanken, schreibt der Test in die eine und sucht in der anderen. Die Meldung heißt dann „0 Treffer“ und sagt nichts über die Ursache (gesehen in #017)
|
- [x] `pnpm beispiel:stammdaten` und `pnpm beispiel:lauf` laufen nur im Modus `INTERN`: sie rufen die Dienste ohne Kunden und scheitern im Modus `DIENSTLEISTUNG` an `MandantFehler: Im Modus Dienstleistung muss ein Kunde gewählt werden`. Das ist richtiges Verhalten der App und eine Lücke der Skripte — wer den Hauptablauf in beiden Modi fahren will, braucht sie in beiden
|
||||||
- [ ] `pnpm docs:screenshots`: Playwright-Skript, Desktop 1440 × 900 und Handy 390 × 844, Seed-Daten, dunkles Design, mit Markierungen (Rahmen, Nummern) für die beschriebenen Stellen; Ablage `docs/anleitung/bilder/`
|
- [x] Der E2E-Lauf prüft beim Start, dass er **dieselbe** Datenbank benutzt wie der Server unter Test. Die Abläufe legen ihre Daten über `prisma` aus `.env` an, die Adresse des Servers steht in `E2E_APP_URL` — zeigen beide auf verschiedene Datenbanken, schreibt der Test in die eine und sucht in der anderen. Die Meldung heißt dann „0 Treffer“ und sagt nichts über die Ursache (gesehen in #017)
|
||||||
- [ ] `pnpm docs:pdf`: Anleitung als PDF über Chromium (`page.pdf`, A4, `printBackground`), Ablage `docs/anleitung/laufzettel-anleitung.pdf`
|
- [x] `pnpm docs:screenshots`: Playwright-Skript, Desktop 1440 × 900 und Handy 390 × 844, Seed-Daten, dunkles Design, mit Markierungen (Rahmen, Nummern) für die beschriebenen Stellen; Ablage `docs/anleitung/bilder/`
|
||||||
- [ ] `docs/anleitung/` auf Deutsch, getrennt für Techniker und Admins: Erste Schritte · Vorlagen bauen · Läufe abarbeiten (Desktop und Handy) · QR-Tags drucken und registrieren · Mitarbeiter und Abteilungen · Betriebsmodus · Admin: Verzeichnis, Benutzer, Einstellungen · Fehlerbehebung
|
- [x] `pnpm docs:pdf`: Anleitung als PDF über Chromium (`page.pdf`, A4, `printBackground`), Ablage `docs/anleitung/laufzettel-anleitung.pdf`
|
||||||
- [ ] `README.md` vollständig: Setup, alle Umgebungsvariablen, Betrieb mit Docker Compose, authentik-Anbindung inkl. Gruppen für Rollen, Seed, Sicherungshinweis für die Datenbank
|
- [x] `docs/anleitung/` auf Deutsch, getrennt für Techniker und Admins: Erste Schritte · Vorlagen bauen · Läufe abarbeiten (Desktop und Handy) · QR-Tags drucken und registrieren · Mitarbeiter und Abteilungen · Betriebsmodus · Admin: Verzeichnis, Benutzer, Einstellungen · Fehlerbehebung
|
||||||
|
- [x] `README.md` vollständig: Setup, alle Umgebungsvariablen, Betrieb mit Docker Compose, authentik-Anbindung inkl. Gruppen für Rollen, Seed, Sicherungshinweis für die Datenbank
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
|
|
||||||
- [ ] Alle Playwright-Abläufe grün, in `INTERN` und in `DIENSTLEISTUNG`
|
- [x] Alle Playwright-Abläufe grün, in `INTERN` und in `DIENSTLEISTUNG`
|
||||||
- [ ] Der Kopier-Knopf liefert exakt den erwarteten Befehlstext, zeichengenau geprüft
|
- [x] Der Kopier-Knopf liefert exakt den erwarteten Befehlstext, zeichengenau geprüft
|
||||||
- [ ] Keine Hauptseite scrollt bei 390 px horizontal
|
- [x] Keine Hauptseite scrollt bei 390 px horizontal
|
||||||
- [ ] `pnpm docs:screenshots` erzeugt alle in der Anleitung verlinkten Bilder reproduzierbar neu
|
- [x] `pnpm docs:screenshots` erzeugt alle in der Anleitung verlinkten Bilder reproduzierbar neu
|
||||||
- [ ] `pnpm pruefe:doku` meldet kein fehlendes Bild
|
- [x] `pnpm pruefe:doku` meldet kein fehlendes Bild
|
||||||
- [ ] Das PDF enthält alle Kapitel mit Bildern, Umlaute korrekt, keine abgeschnittenen Seiten
|
- [x] Das PDF enthält alle Kapitel mit Bildern, Umlaute korrekt, keine abgeschnittenen Seiten
|
||||||
- [ ] Die README beschreibt authentik so, dass ein Dritter die Anbindung ohne Rückfrage hinbekommt
|
- [x] Die README beschreibt authentik so, dass ein Dritter die Anbindung ohne Rückfrage hinbekommt
|
||||||
|
|
||||||
|
## Ergebnis
|
||||||
|
|
||||||
|
Erledigt am 2026-09-26. **101 Ende-zu-Ende-Abläufe** im Modus `INTERN` und **97** im Modus
|
||||||
|
`DIENSTLEISTUNG`, beide ohne Fehlschlag; Vitest unverändert bei 1130. Die Anleitung steht unter
|
||||||
|
`docs/anleitung/` mit 35 Bildern, das PDF hat 72 Seiten.
|
||||||
|
|
||||||
|
### Was die Abläufe jetzt anders machen
|
||||||
|
|
||||||
|
- **Die Voraussetzungen werden geprüft, nicht behauptet.** `tests/e2e/global-setup.ts` fragt vor dem
|
||||||
|
ersten Ablauf: antwortet ein gebauter Stand, steht der Seed-Bestand, und liest der Server
|
||||||
|
**dieselbe** Datenbank wie der Testprozess? Die dritte Frage hat in #017 einen halben Abend
|
||||||
|
gekostet, weil ihre Antwort „0 Treffer" hieß.
|
||||||
|
- **Jeder Ablauf legt an, was er braucht, und räumt es weg.** Keine Vorlage über `index: 1`, keine
|
||||||
|
Annahme „die Datenbank enthält schon Läufe", kein `test.skip` bei fehlenden Daten.
|
||||||
|
- **Zehn Kopien von `anmelden()`** sind zu einer geworden.
|
||||||
|
|
||||||
|
### Drei Fehler, die dabei herauskamen
|
||||||
|
|
||||||
|
1. **Das Projekt „handy" prüfte 412 px statt 390.** `devices["Pixel 7"]` ist 412 breit; CLAUDE.md und
|
||||||
|
das Abnahmekriterium nennen 390. Jeder „trägt am Handy ohne horizontales Scrollen"-Test lief
|
||||||
|
gegen eine Breite, die niemand verlangt hatte. Nach der Korrektur blieb alles grün — die Zusage
|
||||||
|
hielt, sie war nur nie geprüft.
|
||||||
|
2. **Eine Auskunft, die niemand je gesehen hat.** Ein angemeldeter Benutzer ohne Recht wurde auf
|
||||||
|
`/anmelden?fehler=keinrecht` geschickt, und die Anmeldeseite leitet einen Angemeldeten sofort
|
||||||
|
weiter. Die Meldung war unerreichbar, der Kommentar im Code versprach sie. Jetzt gibt es
|
||||||
|
`/kein-zugriff` mit der eigenen Rolle darin.
|
||||||
|
3. **Zwei Tests, die Schaden anrichteten:** der Verzeichnis-Ablauf schrieb die Attributzuordnung der
|
||||||
|
**ersten vorhandenen** Quelle um — in einer echten Installation die produktive; und ein Nachlauf
|
||||||
|
setzte die Namensregeln auf leer, statt den vorherigen Wert zurückzustellen.
|
||||||
|
|
||||||
|
### Der zweite Betriebsmodus
|
||||||
|
|
||||||
|
Der erste Durchgang im Modus `DIENSTLEISTUNG` hatte **28 rote Abläufe**, sechs Ursachen, alle
|
||||||
|
dieselbe blinde Stelle: die Kundenebene. Dort hat jedes Anlege-Formular ein Pflichtfeld mehr, und
|
||||||
|
dahinter steht eine Regel, die nicht verhandelbar ist — ein Lauf kann **strukturell** kein Gerät und
|
||||||
|
keine Abteilung eines fremden Kunden wählen, dafür sorgen zusammengesetzte Fremdschlüssel. „Irgendein
|
||||||
|
Kunde" ist dort nicht ungenau, sondern rot. Beim Onboarding war es sogar eine Reihenfolge: die
|
||||||
|
Kundenwahl stand hinter dem Namensvorschlag, und ohne Kunden greifen die Namensregeln nicht.
|
||||||
|
|
||||||
|
Ohne dieses Abnahmekriterium wäre der zweite Betriebsmodus ungeprüft ins Release gegangen.
|
||||||
|
|
||||||
|
### Beim Schreiben der Anleitung aufgefallen
|
||||||
|
|
||||||
|
Zwei Stellen, an denen die App etwas kann, das niemand bedienen kann — beide als Issue festgehalten,
|
||||||
|
nicht weggeschwiegen: [#022](022-schnellstart-pflegen.md) (Schnellstart je Gerätetyp setzt nur der
|
||||||
|
Seed) und [#023](023-protokoll-ansicht.md) (das Protokoll wird geschrieben, aber nie angezeigt).
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ Zwei erprobte Wege in den Betrieb und ein sauberes erstes Release.
|
|||||||
- [ ] Devlog-Phasenzusammenfassung: Gesamtstunden, Stunden je Meilenstein, größte Zeitfresser, Abweichung vom Plan
|
- [ ] Devlog-Phasenzusammenfassung: Gesamtstunden, Stunden je Meilenstein, größte Zeitfresser, Abweichung vom Plan
|
||||||
- [ ] `CHANGELOG.md` für `v0.1.0` mit Datum, Version in `package.json`, Tag `v0.1.0`, Gitea-Release mit Changelog-Auszug, Anleitung als PDF beigelegt
|
- [ ] `CHANGELOG.md` für `v0.1.0` mit Datum, Version in `package.json`, Tag `v0.1.0`, Gitea-Release mit Changelog-Auszug, Anleitung als PDF beigelegt
|
||||||
|
|
||||||
|
- [ ] Die Anleitung als PDF (`pnpm docs:pdf`) dem Gitea-Release als Datei beilegen — im Repo liegt sie nicht, sie entsteht aus den Kapiteln und Bildern
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
|
|
||||||
- [ ] `docker run --rm debian:13` mit `deploy/install.sh` installiert vollständig durch: Postgres läuft, Migration und Seed gelaufen, Dienst antwortet auf `/api/gesundheit`
|
- [ ] `docker run --rm debian:13` mit `deploy/install.sh` installiert vollständig durch: Postgres läuft, Migration und Seed gelaufen, Dienst antwortet auf `/api/gesundheit`
|
||||||
|
|||||||
@@ -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 |
|
| #015 | [M10 – Dashboard und Archiv](015-dashboard-archiv.md) | erledigt | v0.1.0 | 8 h | 2 h 20 min |
|
||||||
| #016 | [M11 – Verzeichnis und LDAP-Abgleich](016-verzeichnis-ldap.md) | erledigt | v0.1.0 | 12 h | 3 h 25 min |
|
| #016 | [M11 – Verzeichnis und LDAP-Abgleich](016-verzeichnis-ldap.md) | erledigt | v0.1.0 | 12 h | 3 h 25 min |
|
||||||
| #017 | [M12 – Seed der sieben Vorlagen](017-seed-sieben-vorlagen.md) | erledigt | v0.1.0 | 8 h | 1 h 30 min |
|
| #017 | [M12 – Seed der sieben Vorlagen](017-seed-sieben-vorlagen.md) | erledigt | v0.1.0 | 8 h | 1 h 30 min |
|
||||||
| #018 | [M13 – E2E-Tests, Anleitung, PDF](018-e2e-anleitung-pdf.md) | offen | v0.1.0 | 14 h | – |
|
| #018 | [M13 – E2E-Tests, Anleitung, PDF](018-e2e-anleitung-pdf.md) | erledigt | v0.1.0 | 14 h | 2 h 15 min |
|
||||||
| #019 | [M14 – Betriebswege und Release v0.1.0](019-betrieb-release.md) | offen | v0.1.0 | 10 h | – |
|
| #019 | [M14 – Betriebswege und Release v0.1.0](019-betrieb-release.md) | offen | v0.1.0 | 10 h | – |
|
||||||
| #020 | [Vorgesetzter auch für von Hand angelegte Personen](020-vorgesetzter-manuell.md) | offen | v0.2.0 | 3 h | – |
|
| #020 | [Vorgesetzter auch für von Hand angelegte Personen](020-vorgesetzter-manuell.md) | offen | v0.2.0 | 3 h | – |
|
||||||
| #021 | [Stabiler Schlüssel für Seed-Vorlagen](021-stabiler-schluessel-vorlagen.md) | offen | v0.2.0 | 2 h | – |
|
| #021 | [Stabiler Schlüssel für Seed-Vorlagen](021-stabiler-schluessel-vorlagen.md) | offen | v0.2.0 | 2 h | – |
|
||||||
| | **Summe** | | | **141 h** | **27 h 40 min** |
|
| #022 | [Schnellstart je Gerätetyp pflegen](022-schnellstart-pflegen.md) | offen | v0.2.0 | 3 h | – |
|
||||||
|
| #023 | [Protokoll ansehen](023-protokoll-ansicht.md) | offen | v0.2.0 | 4 h | – |
|
||||||
|
| | **Summe** | | | **148 h** | **29 h 55 min** |
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ const APP_URL = process.env["E2E_APP_URL"] ?? "http://127.0.0.1:3000";
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./tests/e2e",
|
testDir: "./tests/e2e",
|
||||||
|
/*
|
||||||
|
* Prueft vor dem ersten Ablauf, dass die Voraussetzungen stimmen: ein gebauter
|
||||||
|
* Stand antwortet, der Seed-Bestand steht, und der Server liest **dieselbe**
|
||||||
|
* Datenbank wie dieser Prozess. Ohne das faellt jede fehlende Voraussetzung
|
||||||
|
* erst mitten im Lauf auf — als Zeitablauf oder als „0 Treffer“ (#018).
|
||||||
|
*/
|
||||||
|
globalSetup: "./tests/e2e/global-setup.ts",
|
||||||
// Die Ablaeufe aendern gemeinsame Daten, deshalb nacheinander.
|
// Die Ablaeufe aendern gemeinsame Daten, deshalb nacheinander.
|
||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
workers: 1,
|
workers: 1,
|
||||||
@@ -20,6 +27,16 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
projects: [
|
projects: [
|
||||||
{ name: "desktop", use: { ...devices["Desktop Chrome"], viewport: { width: 1440, height: 900 } } },
|
{ name: "desktop", use: { ...devices["Desktop Chrome"], viewport: { width: 1440, height: 900 } } },
|
||||||
{ name: "handy", use: { ...devices["Pixel 7"] } },
|
{
|
||||||
|
/*
|
||||||
|
* **390 px, nicht 412.** `devices["Pixel 7"]` ist 412 × 839; CLAUDE.md und
|
||||||
|
* das Abnahmekriterium nennen 390. Zweiundzwanzig Pixel sind kein
|
||||||
|
* Rundungsfehler — eine Seite, die bei 390 ausbricht, sieht bei 412
|
||||||
|
* tadellos aus. Jeder „trägt am Handy ohne horizontales Scrollen“-Test lief
|
||||||
|
* damit gegen eine Breite, die niemand verlangt hatte (#018).
|
||||||
|
*/
|
||||||
|
name: "handy",
|
||||||
|
use: { ...devices["Pixel 7"], viewport: { width: 390, height: 844 } },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.
|
* wie nebenan, nur groesser.
|
||||||
*/
|
*/
|
||||||
readonly ausschnitt?: (tab: Page) => Locator;
|
readonly ausschnitt?: (tab: Page) => Locator;
|
||||||
|
/**
|
||||||
|
* Bereiche, die im Bild hervorgehoben werden – Rahmen in der Signal-Lime mit
|
||||||
|
* einer Nummer, auf die der Text der Anleitung sich beziehen kann (#018).
|
||||||
|
* Ohne Angabe entsteht genau dasselbe Bild wie vorher.
|
||||||
|
*/
|
||||||
|
readonly markierungen?: readonly Markierung[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine Markierung im Bild. Die Nummer kommt aus der Reihenfolge – sie im Text
|
||||||
|
* der Anleitung zu nennen ist der ganze Zweck („(1) zeigt den Fortschritt“).
|
||||||
|
*/
|
||||||
|
type Markierung = {
|
||||||
|
readonly ziel: (tab: Page) => Locator;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Seiten, die abgebildet werden. Waechst mit jedem Meilenstein. */
|
/** Seiten, die abgebildet werden. Waechst mit jedem Meilenstein. */
|
||||||
@@ -137,7 +151,16 @@ const SEITEN: readonly Seite[] = [
|
|||||||
},
|
},
|
||||||
{ name: "geraete-liste", pfad: "/geraete" },
|
{ name: "geraete-liste", pfad: "/geraete" },
|
||||||
{ name: "laeufe-liste", pfad: "/laeufe" },
|
{ name: "laeufe-liste", pfad: "/laeufe" },
|
||||||
{ name: "lauf-starten", pfad: "/laeufe/starten" },
|
{
|
||||||
|
name: "lauf-starten",
|
||||||
|
pfad: "/laeufe/starten",
|
||||||
|
// (1) Vorlage, (2) Geraet – die zwei Felder, ueber die das Kapitel „Lauf
|
||||||
|
// starten“ redet; alles darunter ist freiwillig.
|
||||||
|
markierungen: [
|
||||||
|
{ ziel: (tab) => tab.locator('select[name="vorlagenVersionId"]') },
|
||||||
|
{ ziel: (tab) => tab.locator('select[name="geraetId"]') },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "lauf-abarbeiten",
|
name: "lauf-abarbeiten",
|
||||||
pfad: "/laeufe",
|
pfad: "/laeufe",
|
||||||
@@ -148,6 +171,24 @@ const SEITEN: readonly Seite[] = [
|
|||||||
.click();
|
.click();
|
||||||
await tab.waitForURL(/\/laeufe\/[^/]+$/);
|
await tab.waitForURL(/\/laeufe\/[^/]+$/);
|
||||||
},
|
},
|
||||||
|
// (1) Fortschritt, (2) Zustandspillen des ersten Schritts, (3) Kommentare.
|
||||||
|
markierungen: [
|
||||||
|
{ ziel: (tab) => tab.getByRole("progressbar") },
|
||||||
|
{
|
||||||
|
ziel: (tab) =>
|
||||||
|
tab
|
||||||
|
.getByRole("listitem")
|
||||||
|
.filter({ has: tab.getByRole("button", { name: "erledigt", exact: true }) })
|
||||||
|
.first()
|
||||||
|
.getByRole("button", { name: "erledigt", exact: true })
|
||||||
|
// Nicht die Pille allein, sondern die Zeile mit allen drei Zustaenden.
|
||||||
|
.locator(".."),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ziel: (tab) =>
|
||||||
|
tab.locator("section").filter({ has: tab.getByRole("heading", { name: "Kommentare zum Lauf" }) }),
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// Der Befehlsschritt, aufgeklappt und in den Blick gescrollt: am Handy
|
// Der Befehlsschritt, aufgeklappt und in den Blick gescrollt: am Handy
|
||||||
@@ -176,6 +217,15 @@ const SEITEN: readonly Seite[] = [
|
|||||||
await tab.getByRole("link", { name: "NB-001" }).first().click();
|
await tab.getByRole("link", { name: "NB-001" }).first().click();
|
||||||
await tab.waitForURL(/\/geraete\/[^/]+$/);
|
await tab.waitForURL(/\/geraete\/[^/]+$/);
|
||||||
},
|
},
|
||||||
|
// (1) der Weg zum QR-Code, (2) was schon zugewiesen ist,
|
||||||
|
// (3) der Weg zu einer weiteren Vorlage.
|
||||||
|
markierungen: [
|
||||||
|
// Der Code selbst steht in einem zugeklappten `details` und waere im Bild
|
||||||
|
// nicht zu sehen – markiert wird deshalb der Griff, der ihn aufklappt.
|
||||||
|
{ ziel: (tab) => tab.locator("summary", { hasText: "QR-Code anzeigen" }) },
|
||||||
|
{ ziel: (tab) => tab.getByRole("heading", { name: "Zugewiesen" }).locator("..") },
|
||||||
|
{ ziel: (tab) => tab.getByRole("heading", { name: "Vorlage zuweisen" }).locator("..") },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
/*
|
/*
|
||||||
@@ -340,6 +390,64 @@ async function kachelnErzeugen(browser: Browser): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt die Markierungen als Overlay in die Seite, bevor das Bild entsteht.
|
||||||
|
*
|
||||||
|
* Gezeichnet wird im Browser und in Dokumentkoordinaten (`getBoundingClientRect`
|
||||||
|
* plus `scrollX`/`scrollY`), damit der Rahmen auch auf einem `fullPage`-Bild dort
|
||||||
|
* sitzt, wo er hingehoert. Nachbearbeitet wird kein Bild – wer die Oberflaeche
|
||||||
|
* aendert, laesst dieses Skript erneut laufen, und die Rahmen wandern mit.
|
||||||
|
*
|
||||||
|
* Findet eine Markierung ihr Ziel nicht, bricht der Lauf ab: ein Bild mit
|
||||||
|
* fehlender „(2)“ macht den Text der Anleitung falsch.
|
||||||
|
*/
|
||||||
|
async function markierungenEinspritzen(
|
||||||
|
tab: Page,
|
||||||
|
seitenname: string,
|
||||||
|
markierungen: readonly Markierung[],
|
||||||
|
): Promise<void> {
|
||||||
|
for (const [stelle, markierung] of markierungen.entries()) {
|
||||||
|
const ziel = markierung.ziel(tab).first();
|
||||||
|
if ((await ziel.count()) === 0) {
|
||||||
|
throw new Error(`Markierung ${stelle + 1} von "${seitenname}" findet ihr Ziel nicht.`);
|
||||||
|
}
|
||||||
|
await ziel.evaluate((element, nummer) => {
|
||||||
|
const kasten = element.getBoundingClientRect();
|
||||||
|
const links = kasten.left + window.scrollX;
|
||||||
|
const oben = kasten.top + window.scrollY;
|
||||||
|
const rahmen = document.createElement("div");
|
||||||
|
Object.assign(rahmen.style, {
|
||||||
|
position: "absolute",
|
||||||
|
left: `${links - 5}px`,
|
||||||
|
top: `${oben - 5}px`,
|
||||||
|
width: `${kasten.width + 10}px`,
|
||||||
|
height: `${kasten.height + 10}px`,
|
||||||
|
border: "2px solid #C6F135",
|
||||||
|
borderRadius: "14px",
|
||||||
|
pointerEvents: "none",
|
||||||
|
zIndex: "2147483647",
|
||||||
|
});
|
||||||
|
const marke = document.createElement("div");
|
||||||
|
marke.textContent = String(nummer);
|
||||||
|
Object.assign(marke.style, {
|
||||||
|
position: "absolute",
|
||||||
|
// Am linken Rand rutscht die Nummer nach innen, sonst schnitte sie das Bild ab.
|
||||||
|
left: links < 24 ? "4px" : "-14px",
|
||||||
|
top: "-14px",
|
||||||
|
width: "26px",
|
||||||
|
height: "26px",
|
||||||
|
borderRadius: "999px",
|
||||||
|
background: "#C6F135",
|
||||||
|
color: "#0B0D10",
|
||||||
|
font: "700 15px/26px ui-sans-serif, system-ui, sans-serif",
|
||||||
|
textAlign: "center",
|
||||||
|
});
|
||||||
|
rahmen.append(marke);
|
||||||
|
document.body.append(rahmen);
|
||||||
|
}, stelle + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const zielMeilenstein = argument("ziel");
|
const zielMeilenstein = argument("ziel");
|
||||||
const nur = argument("nur");
|
const nur = argument("nur");
|
||||||
/** Welcher Betriebsmodus gerade eingestellt ist – steuert, welche Seiten passen. */
|
/** Welcher Betriebsmodus gerade eingestellt ist – steuert, welche Seiten passen. */
|
||||||
@@ -376,6 +484,7 @@ try {
|
|||||||
// Schriften muessen geladen sein, sonst wackelt das Bild zwischen Laeufen.
|
// Schriften muessen geladen sein, sonst wackelt das Bild zwischen Laeufen.
|
||||||
await tab.evaluate(() => document.fonts.ready);
|
await tab.evaluate(() => document.fonts.ready);
|
||||||
await tab.waitForLoadState("domcontentloaded");
|
await tab.waitForLoadState("domcontentloaded");
|
||||||
|
if (seite.markierungen) await markierungenEinspritzen(tab, seite.name, seite.markierungen);
|
||||||
|
|
||||||
const datei = join(ziel, `${seite.name}-${ansicht.name}.png`);
|
const datei = join(ziel, `${seite.name}-${ansicht.name}.png`);
|
||||||
if (seite.ausschnitt) {
|
if (seite.ausschnitt) {
|
||||||
|
|||||||
@@ -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-aus": "Die Anmeldung über den Anbieter ist nicht eingerichtet.",
|
||||||
"oidc-start": "Der Anbieter war nicht erreichbar. Bitte später erneut versuchen.",
|
"oidc-start": "Der Anbieter war nicht erreichbar. Bitte später erneut versuchen.",
|
||||||
"oidc-rueckruf": "Die Anmeldung beim Anbieter ist fehlgeschlagen.",
|
"oidc-rueckruf": "Die Anmeldung beim Anbieter ist fehlgeschlagen.",
|
||||||
keinrecht: "Für diesen Bereich fehlt dir die Berechtigung.",
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -87,11 +87,18 @@ export async function sitzungErforderlich(recht?: Recht, ziel?: string): Promise
|
|||||||
redirect(ziel ? `/anmelden?ziel=${encodeURIComponent(ziel)}` : "/anmelden");
|
redirect(ziel ? `/anmelden?ziel=${encodeURIComponent(ziel)}` : "/anmelden");
|
||||||
}
|
}
|
||||||
if (recht && !darf(vorhanden.benutzer.rolle, recht)) {
|
if (recht && !darf(vorhanden.benutzer.rolle, recht)) {
|
||||||
// Bei einer Seite ist ein geworfener Fehler eine 500er-Seite – fuer jemanden,
|
/*
|
||||||
// der einfach ein altes Lesezeichen geoeffnet hat, die falsche Auskunft.
|
* Bei einer Seite ist ein geworfener Fehler eine 500er-Seite – fuer jemanden,
|
||||||
// Deshalb hier eine Weiterleitung mit Erklaerung; in Server Actions bleibt
|
* der einfach ein altes Lesezeichen geoeffnet hat, die falsche Auskunft.
|
||||||
// das Werfen richtig, siehe rechtErforderlich.
|
* Deshalb eine Weiterleitung mit Erklaerung; in Server Actions bleibt das
|
||||||
redirect("/anmelden?fehler=keinrecht");
|
* Werfen richtig, siehe `rechtErforderlich`.
|
||||||
|
*
|
||||||
|
* Und zwar auf **`/kein-zugriff`**, nicht auf die Anmeldeseite: dort leitet
|
||||||
|
* ein Angemeldeter sofort auf die Uebersicht weiter, und die Erklaerung war
|
||||||
|
* damit unerreichbar. Der Kommentar hier versprach sie trotzdem — anderthalb
|
||||||
|
* Jahre haette niemand gemerkt, dass der Hinweis nie ankommt (#018).
|
||||||
|
*/
|
||||||
|
redirect(ziel ? `/kein-zugriff?bereich=${encodeURIComponent(ziel)}` : "/kein-zugriff");
|
||||||
}
|
}
|
||||||
return vorhanden;
|
return vorhanden;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { expect, test, type Page } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import { anmelden, SEED_VORLAGE } from "./helfer";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Das Archiv am gebauten Stand (#015).
|
* Das Archiv am gebauten Stand (#015).
|
||||||
@@ -9,20 +10,18 @@ import { prisma } from "@/lib/db";
|
|||||||
* Mandantengrenze hinein. Hier geht es um den Weg, den ein Mensch nimmt: Wort
|
* Mandantengrenze hinein. Hier geht es um den Weg, den ein Mensch nimmt: Wort
|
||||||
* eintippen, Treffer sehen, **erkennen warum**, in den Lauf springen. Ein
|
* eintippen, Treffer sehen, **erkennen warum**, in den Lauf springen. Ein
|
||||||
* Treffer ohne sichtbaren Grund ist fuer den Suchenden wertlos.
|
* Treffer ohne sichtbaren Grund ist fuer den Suchenden wertlos.
|
||||||
|
*
|
||||||
|
* Voraussetzungen prueft `tests/e2e/global-setup.ts`, aufgeraeumt wird am Ende
|
||||||
|
* ueber die Datenbank: fuer einen Lauf kennt die Anwendung keinen Loeschweg, und
|
||||||
|
* das ist richtig so (#018).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const STEMPEL = Date.now();
|
const STEMPEL = Date.now();
|
||||||
const NOTIZ = `Restposten ${STEMPEL} aus dem Lager`;
|
|
||||||
|
|
||||||
async function anmelden(seite: Page) {
|
/** Fortlaufend, damit zwei Laeufe desselben Durchgangs sich nicht verwechseln. */
|
||||||
const email = process.env["SEED_ADMIN_EMAIL"];
|
let laufende = 0;
|
||||||
const passwort = process.env["SEED_ADMIN_PASSWORD"];
|
|
||||||
if (!email || !passwort) throw new Error("SEED_ADMIN_EMAIL und SEED_ADMIN_PASSWORD fehlen.");
|
type Testlauf = { readonly id: string; readonly titel: string; readonly suchwort: string };
|
||||||
await seite.goto("/anmelden");
|
|
||||||
await seite.fill('input[name="email"]', email);
|
|
||||||
await seite.fill('input[name="passwort"]', passwort);
|
|
||||||
await Promise.all([seite.waitForURL("**/"), seite.click('button[type="submit"]')]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ein archivierter Lauf mit einer Notiz, die es sonst nirgends gibt.
|
* Ein archivierter Lauf mit einer Notiz, die es sonst nirgends gibt.
|
||||||
@@ -31,19 +30,31 @@ async function anmelden(seite: Page) {
|
|||||||
* geprueft werden, nicht der Weg dorthin — den prueft `tests/e2e/laeufe.spec.ts`
|
* geprueft werden, nicht der Weg dorthin — den prueft `tests/e2e/laeufe.spec.ts`
|
||||||
* vom Anlegen bis zum Archivieren. Die Zeitstempel gehoeren trotzdem dazu, drei
|
* vom Anlegen bis zum Archivieren. Die Zeitstempel gehoeren trotzdem dazu, drei
|
||||||
* CHECK-Bedingungen in der Datenbank verlangen sie.
|
* CHECK-Bedingungen in der Datenbank verlangen sie.
|
||||||
|
*
|
||||||
|
* Vorlage **am Namen**, Kunde ueber `istEigeneOrganisation`: ein
|
||||||
|
* `findFirstOrThrow` ohne Bedingung nahm die Version, die die Sortierung gerade
|
||||||
|
* nach vorn stellte, und im Modus DIENSTLEISTUNG einen beliebigen Demo-Kunden.
|
||||||
|
* Dass es `SEED_VORLAGE` veroeffentlicht gibt, prueft der Global-Setup.
|
||||||
*/
|
*/
|
||||||
async function archivierterLauf(): Promise<{ id: string; titel: string }> {
|
async function archivierterLauf(): Promise<Testlauf> {
|
||||||
|
laufende += 1;
|
||||||
|
const marke = `${STEMPEL}-${laufende}`;
|
||||||
|
const suchwort = `Restposten ${marke}`;
|
||||||
|
|
||||||
const version = await prisma.vorlagenVersion.findFirstOrThrow({
|
const version = await prisma.vorlagenVersion.findFirstOrThrow({
|
||||||
where: { status: "VEROEFFENTLICHT" },
|
where: { status: "VEROEFFENTLICHT", vorlage: { name: SEED_VORLAGE } },
|
||||||
orderBy: { veroeffentlichtAm: "desc" },
|
orderBy: { version: "desc" },
|
||||||
select: { id: true, abschnitte: { select: { schritte: { select: { id: true } } } } },
|
select: { id: true, abschnitte: { select: { schritte: { select: { id: true } } } } },
|
||||||
});
|
});
|
||||||
const kunde = await prisma.kunde.findFirstOrThrow({ select: { id: true } });
|
const kunde = await prisma.kunde.findFirstOrThrow({
|
||||||
|
where: { istEigeneOrganisation: true },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
const schrittId = version.abschnitte.flatMap((abschnitt) => abschnitt.schritte)[0]?.id;
|
const schrittId = version.abschnitte.flatMap((abschnitt) => abschnitt.schritte)[0]?.id;
|
||||||
if (!schrittId) throw new Error("Die Vorlage hat keinen Schritt.");
|
if (!schrittId) throw new Error(`Die Vorlage „${SEED_VORLAGE}“ hat keinen Schritt.`);
|
||||||
|
|
||||||
const jetzt = new Date();
|
const jetzt = new Date();
|
||||||
const titel = `ZZ E2E Archiv ${STEMPEL}`;
|
const titel = `ZZ E2E Archiv ${marke}`;
|
||||||
const lauf = await prisma.lauf.create({
|
const lauf = await prisma.lauf.create({
|
||||||
data: {
|
data: {
|
||||||
vorlagenVersionId: version.id,
|
vorlagenVersionId: version.id,
|
||||||
@@ -60,10 +71,10 @@ async function archivierterLauf(): Promise<{ id: string; titel: string }> {
|
|||||||
laufId: lauf.id,
|
laufId: lauf.id,
|
||||||
vorlagenSchrittId: schrittId,
|
vorlagenSchrittId: schrittId,
|
||||||
zustand: "NICHT_ZUTREFFEND",
|
zustand: "NICHT_ZUTREFFEND",
|
||||||
notiz: NOTIZ,
|
notiz: `${suchwort} aus dem Lager`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return { id: lauf.id, titel };
|
return { id: lauf.id, titel, suchwort };
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe("Archiv", () => {
|
test.describe("Archiv", () => {
|
||||||
@@ -77,7 +88,7 @@ test.describe("Archiv", () => {
|
|||||||
}
|
}
|
||||||
await page.getByRole("link", { name: "Archiv" }).first().click();
|
await page.getByRole("link", { name: "Archiv" }).first().click();
|
||||||
await page.waitForURL("**/archiv");
|
await page.waitForURL("**/archiv");
|
||||||
await page.getByLabel("Suchbegriff").fill(`Restposten ${STEMPEL}`);
|
await page.getByLabel("Suchbegriff").fill(lauf.suchwort);
|
||||||
await page.getByRole("button", { name: "Suchen" }).click();
|
await page.getByRole("button", { name: "Suchen" }).click();
|
||||||
|
|
||||||
const treffer = page.getByRole("listitem").filter({ hasText: lauf.titel });
|
const treffer = page.getByRole("listitem").filter({ hasText: lauf.titel });
|
||||||
@@ -102,8 +113,16 @@ test.describe("Archiv", () => {
|
|||||||
|
|
||||||
test("trägt am Handy ohne horizontales Scrollen", async ({ page, isMobile }) => {
|
test("trägt am Handy ohne horizontales Scrollen", async ({ page, isMobile }) => {
|
||||||
test.skip(!isMobile, "Nur im Projekt „handy“.");
|
test.skip(!isMobile, "Nur im Projekt „handy“.");
|
||||||
|
/*
|
||||||
|
* Der Ablauf bringt seinen Treffer mit, statt auf einen zu hoffen: gegen
|
||||||
|
* eine frische Datenbank fand „Restposten“ nichts, und gemessen wurde dann
|
||||||
|
* die Breite einer Seite ohne Treffer (#018).
|
||||||
|
*/
|
||||||
|
const lauf = await archivierterLauf();
|
||||||
await anmelden(page);
|
await anmelden(page);
|
||||||
await page.goto("/archiv?suche=Restposten");
|
await page.goto(`/archiv?suche=${encodeURIComponent(lauf.suchwort)}`);
|
||||||
|
await expect(page.getByRole("listitem").filter({ hasText: lauf.titel })).toHaveCount(1);
|
||||||
|
|
||||||
const masse = await page.evaluate(() => ({
|
const masse = await page.evaluate(() => ({
|
||||||
scroll: document.documentElement.scrollWidth,
|
scroll: document.documentElement.scrollWidth,
|
||||||
sichtbar: document.documentElement.clientWidth,
|
sichtbar: document.documentElement.clientWidth,
|
||||||
@@ -114,6 +133,7 @@ test.describe("Archiv", () => {
|
|||||||
|
|
||||||
test.afterAll(async () => {
|
test.afterAll(async () => {
|
||||||
const laeufe = await prisma.lauf.findMany({
|
const laeufe = await prisma.lauf.findMany({
|
||||||
|
// Der Praefix gehoert dieser Datei allein – geraeumt wird nur, was sie anlegt.
|
||||||
where: { titel: { startsWith: "ZZ E2E Archiv" } },
|
where: { titel: { startsWith: "ZZ E2E Archiv" } },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { expect, test, type Page } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import { prisma } from "@/lib/db";
|
import { anmelden, SEED_TAG } from "./helfer";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Der Etikettendruck am gebauten Stand (#014).
|
* Der Etikettendruck am gebauten Stand (#014).
|
||||||
@@ -14,34 +14,21 @@ import { prisma } from "@/lib/db";
|
|||||||
* Die Aufloesung: 1 mm sind 96/25,4 px, ein `deviceScaleFactor` von 300/96 =
|
* Die Aufloesung: 1 mm sind 96/25,4 px, ein `deviceScaleFactor` von 300/96 =
|
||||||
* 3,125 macht daraus 300 dpi. 62 mm werden so zu 62 · 96/25,4 · 3,125 ≈ 732
|
* 3,125 macht daraus 300 dpi. 62 mm werden so zu 62 · 96/25,4 · 3,125 ≈ 732
|
||||||
* Geraetepixeln — genau die Aufloesung, mit der ein Etikettendrucker arbeitet.
|
* Geraetepixeln — genau die Aufloesung, mit der ein Etikettendrucker arbeitet.
|
||||||
|
*
|
||||||
|
* Gedruckt wird `SEED_TAG` und nicht irgendein Code aus der Datenbank: dass es
|
||||||
|
* ihn gibt, prueft `tests/e2e/global-setup.ts` vor dem ersten Ablauf. Ein
|
||||||
|
* `findFirst` haette gegen eine frische Installation „kein Geraet“ gemeldet und
|
||||||
|
* gegen eine benutzte einen beliebigen Aufkleber gedruckt (#018).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const DPI = 300;
|
const DPI = 300;
|
||||||
const PX_JE_MM = 96 / 25.4;
|
const PX_JE_MM = 96 / 25.4;
|
||||||
|
|
||||||
async function anmelden(seite: Page) {
|
|
||||||
const email = process.env["SEED_ADMIN_EMAIL"];
|
|
||||||
const passwort = process.env["SEED_ADMIN_PASSWORD"];
|
|
||||||
if (!email || !passwort) throw new Error("SEED_ADMIN_EMAIL und SEED_ADMIN_PASSWORD fehlen.");
|
|
||||||
await seite.goto("/anmelden");
|
|
||||||
await seite.fill('input[name="email"]', email);
|
|
||||||
await seite.fill('input[name="passwort"]', passwort);
|
|
||||||
await Promise.all([seite.waitForURL("**/"), seite.click('button[type="submit"]')]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Irgendein Tag-Code, den es in dieser Datenbank wirklich gibt. */
|
|
||||||
async function einTagCode(): Promise<string> {
|
|
||||||
const geraet = await prisma.geraet.findFirst({ select: { tagCode: true } });
|
|
||||||
if (!geraet) throw new Error("Kein Gerät in der Datenbank — erst pnpm db:seed.");
|
|
||||||
return geraet.tagCode;
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe("Etikettendruck", () => {
|
test.describe("Etikettendruck", () => {
|
||||||
test("druckt ein Einzeletikett auf 62 × 29 mm", async ({ page }) => {
|
test("druckt ein Einzeletikett auf 62 × 29 mm", async ({ page }) => {
|
||||||
test.skip(test.info().project.name !== "desktop", "Das Seitenformat hängt nicht am Gerät.");
|
test.skip(test.info().project.name !== "desktop", "Das Seitenformat hängt nicht am Gerät.");
|
||||||
await anmelden(page);
|
await anmelden(page);
|
||||||
const code = await einTagCode();
|
await page.goto(`/drucken/etiketten?layout=etikett&codes=${SEED_TAG}`);
|
||||||
await page.goto(`/drucken/etiketten?layout=etikett&codes=${code}`);
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* `page.pdf()` gilt in Chromium und beachtet `@page`. Gemessen wird die
|
* `page.pdf()` gilt in Chromium und beachtet `@page`. Gemessen wird die
|
||||||
@@ -69,7 +56,6 @@ test.describe("Etikettendruck", () => {
|
|||||||
|
|
||||||
test("ein bei 300 dpi gerastertes Etikett lässt sich zurücklesen", async ({ browser }) => {
|
test("ein bei 300 dpi gerastertes Etikett lässt sich zurücklesen", async ({ browser }) => {
|
||||||
test.skip(test.info().project.name !== "desktop", "Der Decoder hängt nicht am Gerät.");
|
test.skip(test.info().project.name !== "desktop", "Der Decoder hängt nicht am Gerät.");
|
||||||
const code = await einTagCode();
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Eigener Kontext: der `deviceScaleFactor` ist die Aufloesung, und die
|
* Eigener Kontext: der `deviceScaleFactor` ist die Aufloesung, und die
|
||||||
@@ -84,7 +70,7 @@ test.describe("Etikettendruck", () => {
|
|||||||
try {
|
try {
|
||||||
const tab = await kontext.newPage();
|
const tab = await kontext.newPage();
|
||||||
await anmelden(tab);
|
await anmelden(tab);
|
||||||
await tab.goto(`/drucken/etiketten?layout=etikett&codes=${code}`);
|
await tab.goto(`/drucken/etiketten?layout=etikett&codes=${SEED_TAG}`);
|
||||||
const etikett = tab.locator("[data-etikett]").first();
|
const etikett = tab.locator("[data-etikett]").first();
|
||||||
await expect(etikett).toBeVisible();
|
await expect(etikett).toBeVisible();
|
||||||
const bild = await etikett.screenshot();
|
const bild = await etikett.screenshot();
|
||||||
@@ -100,7 +86,7 @@ test.describe("Etikettendruck", () => {
|
|||||||
* dazulegen koennte.
|
* dazulegen koennte.
|
||||||
*/
|
*/
|
||||||
const adresse = new URL(gelesen[0] ?? "");
|
const adresse = new URL(gelesen[0] ?? "");
|
||||||
expect(adresse.pathname).toBe(`/t/${code}`);
|
expect(adresse.pathname).toBe(`/t/${SEED_TAG}`);
|
||||||
expect(adresse.search).toBe("");
|
expect(adresse.search).toBe("");
|
||||||
expect(adresse.hash).toBe("");
|
expect(adresse.hash).toBe("");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -157,7 +143,3 @@ function seitenmasseAusPdf(pdf: Buffer): { breite: number; hoehe: number } {
|
|||||||
hoehe: Number(treffer[4]) - Number(treffer[2]),
|
hoehe: Number(treffer[4]) - Number(treffer[2]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
test.afterAll(async () => {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { expect, test, type Page } from "@playwright/test";
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
|
import { anmelden, kundeWaehlen } from "./helfer";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Die Geraeteliste durch die Oberflaeche (#011).
|
* Die Geraeteliste durch die Oberflaeche (#011).
|
||||||
@@ -11,8 +12,10 @@ import "dotenv/config";
|
|||||||
* einlesen, loeschen — und anschliessend, ob es wirklich in der Datenbank steht:
|
* einlesen, loeschen — und anschliessend, ob es wirklich in der Datenbank steht:
|
||||||
* durch Neuladen.
|
* durch Neuladen.
|
||||||
*
|
*
|
||||||
* Voraussetzung: ein **gebauter** Stand laeuft (`pnpm build && pnpm start`) und
|
* Voraussetzungen — ein gebauter Stand antwortet, der Seed-Bestand steht und der
|
||||||
* `SEED_ADMIN_EMAIL`/`SEED_ADMIN_PASSWORD` sind gesetzt.
|
* Server liest dieselbe Datenbank wie dieser Lauf — prueft
|
||||||
|
* `tests/e2e/global-setup.ts` vor dem ersten Ablauf (#018). Eigene Daten legt
|
||||||
|
* dieser Ablauf selbst an; auf vorhandene baut er nicht.
|
||||||
*
|
*
|
||||||
* Der Ablauf raeumt hinter sich auf: an den angelegten Geraeten haengt kein Lauf,
|
* Der Ablauf raeumt hinter sich auf: an den angelegten Geraeten haengt kein Lauf,
|
||||||
* sie lassen sich also loeschen. Ihre Tag-Codes bleiben vergeben, und genau das
|
* sie lassen sich also loeschen. Ihre Tag-Codes bleiben vergeben, und genau das
|
||||||
@@ -21,16 +24,6 @@ import "dotenv/config";
|
|||||||
|
|
||||||
const MARKE = `ZZ-E2E-${Date.now()}`;
|
const MARKE = `ZZ-E2E-${Date.now()}`;
|
||||||
|
|
||||||
async function anmelden(seite: Page) {
|
|
||||||
const email = process.env["SEED_ADMIN_EMAIL"];
|
|
||||||
const passwort = process.env["SEED_ADMIN_PASSWORD"];
|
|
||||||
if (!email || !passwort) throw new Error("SEED_ADMIN_EMAIL und SEED_ADMIN_PASSWORD fehlen.");
|
|
||||||
await seite.goto("/anmelden");
|
|
||||||
await seite.fill('input[name="email"]', email);
|
|
||||||
await seite.fill('input[name="passwort"]', passwort);
|
|
||||||
await Promise.all([seite.waitForURL("**/"), seite.click('button[type="submit"]')]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Der Abschnitt „Gerät anlegen“ – eindeutig ueber seinen eigenen Knopf. */
|
/** Der Abschnitt „Gerät anlegen“ – eindeutig ueber seinen eigenen Knopf. */
|
||||||
function anlegenFormular(seite: Page) {
|
function anlegenFormular(seite: Page) {
|
||||||
return seite.locator("section", { has: seite.getByRole("button", { name: "Gerät anlegen" }) });
|
return seite.locator("section", { has: seite.getByRole("button", { name: "Gerät anlegen" }) });
|
||||||
@@ -70,6 +63,7 @@ test.describe("Geräte", () => {
|
|||||||
await formular.getByLabel("Hostname").fill(name);
|
await formular.getByLabel("Hostname").fill(name);
|
||||||
await formular.getByLabel("Seriennummer").fill(`${MARKE}-SN`);
|
await formular.getByLabel("Seriennummer").fill(`${MARKE}-SN`);
|
||||||
await formular.getByLabel("Typ").selectOption("NOTEBOOK");
|
await formular.getByLabel("Typ").selectOption("NOTEBOOK");
|
||||||
|
await kundeWaehlen(formular);
|
||||||
await formular.getByRole("button", { name: "Gerät anlegen" }).click();
|
await formular.getByRole("button", { name: "Gerät anlegen" }).click();
|
||||||
|
|
||||||
// Der Tag-Code steht in der Meldung – er kommt gleich auf den Aufkleber.
|
// Der Tag-Code steht in der Meldung – er kommt gleich auf den Aufkleber.
|
||||||
@@ -109,6 +103,7 @@ test.describe("Geräte", () => {
|
|||||||
|
|
||||||
const formular = anlegenFormular(page);
|
const formular = anlegenFormular(page);
|
||||||
await formular.getByLabel("Hostname").fill(name);
|
await formular.getByLabel("Hostname").fill(name);
|
||||||
|
await kundeWaehlen(formular);
|
||||||
await formular.getByRole("button", { name: "Gerät anlegen" }).click();
|
await formular.getByRole("button", { name: "Gerät anlegen" }).click();
|
||||||
await expect(formular.getByRole("status")).toContainText("wurde angelegt");
|
await expect(formular.getByRole("status")).toContainText("wurde angelegt");
|
||||||
|
|
||||||
@@ -150,6 +145,7 @@ test.describe("Geräte", () => {
|
|||||||
`${MARKE}-CSV-BOESE;;SN-BOESE;INV-2;Tablet;`,
|
`${MARKE}-CSV-BOESE;;SN-BOESE;INV-2;Tablet;`,
|
||||||
].join("\r\n"),
|
].join("\r\n"),
|
||||||
);
|
);
|
||||||
|
await kundeWaehlen(bereich);
|
||||||
await bereich.getByRole("button", { name: "Einlesen" }).click();
|
await bereich.getByRole("button", { name: "Einlesen" }).click();
|
||||||
|
|
||||||
await expect(bereich.getByRole("status")).toContainText("1 von 2 Zeilen angelegt, 1 Zeile abgewiesen.");
|
await expect(bereich.getByRole("status")).toContainText("1 von 2 Zeilen angelegt, 1 Zeile abgewiesen.");
|
||||||
@@ -175,6 +171,7 @@ test.describe("Geräte", () => {
|
|||||||
const formular = anlegenFormular(page);
|
const formular = anlegenFormular(page);
|
||||||
await formular.getByLabel("Bezeichnung").fill(name);
|
await formular.getByLabel("Bezeichnung").fill(name);
|
||||||
await formular.getByLabel("Typ").selectOption("RAUM");
|
await formular.getByLabel("Typ").selectOption("RAUM");
|
||||||
|
await kundeWaehlen(formular);
|
||||||
await formular.getByRole("button", { name: "Gerät anlegen" }).click();
|
await formular.getByRole("button", { name: "Gerät anlegen" }).click();
|
||||||
await expect(formular.getByRole("status")).toContainText("wurde angelegt");
|
await expect(formular.getByRole("status")).toContainText("wurde angelegt");
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { expect, test, type Page } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import { anmelden, SEED_TAG } from "./helfer";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Der Weg vom Aufkleber zur Arbeit (#014).
|
* Der Weg vom Aufkleber zur Arbeit (#014).
|
||||||
@@ -9,19 +10,17 @@ import { prisma } from "@/lib/db";
|
|||||||
* tun ist, loslegen. Geprueft wird er am **gebauten** Stand, weil genau hier die
|
* tun ist, loslegen. Geprueft wird er am **gebauten** Stand, weil genau hier die
|
||||||
* Teile zusammenkommen, die einzeln schon funktionierten — die Weiche unter
|
* Teile zusammenkommen, die einzeln schon funktionierten — die Weiche unter
|
||||||
* `/t/<code>`, das Registrieren, die Geraeteseite und der Lauf.
|
* `/t/<code>`, das Registrieren, die Geraeteseite und der Lauf.
|
||||||
|
*
|
||||||
|
* Voraussetzungen prueft `tests/e2e/global-setup.ts`. Das Beispielgeraet wird
|
||||||
|
* ueber `SEED_TAG` gesucht und nicht ueber den Hostnamen „PC-001“: im Modus
|
||||||
|
* DIENSTLEISTUNG traegt der Name das Kundenkuerzel, und in einer benutzten
|
||||||
|
* Datenbank gibt es „PC-001“ mehr als einmal (#018).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const STEMPEL = Date.now();
|
const STEMPEL = Date.now();
|
||||||
|
|
||||||
async function anmelden(seite: Page) {
|
/** Praefix der Geraete, die dieser Ablauf anlegt – und nur dieser. */
|
||||||
const email = process.env["SEED_ADMIN_EMAIL"];
|
const HOSTNAME_PRAEFIX = "ZZ-E2E-TAG-";
|
||||||
const passwort = process.env["SEED_ADMIN_PASSWORD"];
|
|
||||||
if (!email || !passwort) throw new Error("SEED_ADMIN_EMAIL und SEED_ADMIN_PASSWORD fehlen.");
|
|
||||||
await seite.goto("/anmelden");
|
|
||||||
await seite.fill('input[name="email"]', email);
|
|
||||||
await seite.fill('input[name="passwort"]', passwort);
|
|
||||||
await Promise.all([seite.waitForURL("**/"), seite.click('button[type="submit"]')]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Eine schlichte Vorlage **ohne** Pflichtangaben.
|
* Eine schlichte Vorlage **ohne** Pflichtangaben.
|
||||||
@@ -59,7 +58,18 @@ async function tagAnlegen(): Promise<string> {
|
|||||||
where: { rolle: "ADMIN" },
|
where: { rolle: "ADMIN" },
|
||||||
select: { id: true, rolle: true },
|
select: { id: true, rolle: true },
|
||||||
});
|
});
|
||||||
const stapel = await tagsErzeugen(1, null, { id: admin.id, rolle: admin.rolle });
|
/*
|
||||||
|
* Der Kunde muss mit: im Modus DIENSTLEISTUNG verlangt `kundeErmitteln` eine
|
||||||
|
* Wahl und wirft sonst „Im Modus Dienstleistung muss ein Kunde gewählt
|
||||||
|
* werden“. Genommen wird der Kunde des Beispielgeraets — an ihm haengt auch
|
||||||
|
* die Geraeteseite, die dieser Ablauf danach oeffnet. Im Modus INTERN wird die
|
||||||
|
* Kennung verworfen, die Angabe schadet dort also nicht (#008).
|
||||||
|
*/
|
||||||
|
const beispiel = await prisma.geraet.findUniqueOrThrow({
|
||||||
|
where: { tagCode: SEED_TAG },
|
||||||
|
select: { kundeId: true },
|
||||||
|
});
|
||||||
|
const stapel = await tagsErzeugen(1, beispiel.kundeId, { id: admin.id, rolle: admin.rolle });
|
||||||
const code = stapel.tagCodes[0];
|
const code = stapel.tagCodes[0];
|
||||||
if (!code) throw new Error("Der Stapel kam leer zurück.");
|
if (!code) throw new Error("Der Stapel kam leer zurück.");
|
||||||
eigeneTags.push(code);
|
eigeneTags.push(code);
|
||||||
@@ -82,13 +92,13 @@ test.describe("Vom Aufkleber zur Arbeit", () => {
|
|||||||
* muss er auf **der Geräteseite** stehen und nicht auf der Übersicht — sonst
|
* muss er auf **der Geräteseite** stehen und nicht auf der Übersicht — sonst
|
||||||
* sucht er das Gerät noch einmal, das er gerade in der Hand hält.
|
* sucht er das Gerät noch einmal, das er gerade in der Hand hält.
|
||||||
*/
|
*/
|
||||||
const geraet = await prisma.geraet.findFirstOrThrow({
|
const geraet = await prisma.geraet.findUniqueOrThrow({
|
||||||
where: { hostname: "PC-001" },
|
where: { tagCode: SEED_TAG },
|
||||||
select: { id: true, tagCode: true },
|
select: { id: true, hostname: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.context().clearCookies();
|
await page.context().clearCookies();
|
||||||
await page.goto(`/t/${geraet.tagCode}`);
|
await page.goto(`/t/${SEED_TAG}`);
|
||||||
await page.waitForURL(/\/anmelden\?ziel=/);
|
await page.waitForURL(/\/anmelden\?ziel=/);
|
||||||
|
|
||||||
const email = process.env["SEED_ADMIN_EMAIL"];
|
const email = process.env["SEED_ADMIN_EMAIL"];
|
||||||
@@ -99,7 +109,8 @@ test.describe("Vom Aufkleber zur Arbeit", () => {
|
|||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
|
|
||||||
await page.waitForURL(`**/geraete/${geraet.id}`);
|
await page.waitForURL(`**/geraete/${geraet.id}`);
|
||||||
await expect(page.getByRole("heading", { name: "PC-001" })).toBeVisible();
|
// Der Name kommt aus der Datenbank: er heisst nicht in jedem Modus „PC-001“.
|
||||||
|
await expect(page.getByRole("heading", { name: geraet.hostname ?? SEED_TAG })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("registriert einen vorgedruckten Tag und arbeitet daran weiter", async ({ page }) => {
|
test("registriert einen vorgedruckten Tag und arbeitet daran weiter", async ({ page }) => {
|
||||||
@@ -111,7 +122,7 @@ test.describe("Vom Aufkleber zur Arbeit", () => {
|
|||||||
await page.goto(`/t/${code.toLowerCase()}`);
|
await page.goto(`/t/${code.toLowerCase()}`);
|
||||||
await expect(page.getByRole("heading", { name: "Gerät registrieren" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Gerät registrieren" })).toBeVisible();
|
||||||
|
|
||||||
const hostname = `ZZ-E2E-${STEMPEL}`;
|
const hostname = `${HOSTNAME_PRAEFIX}${STEMPEL}`;
|
||||||
await page.getByLabel("Hostname").fill(hostname);
|
await page.getByLabel("Hostname").fill(hostname);
|
||||||
await page.getByLabel("Typ").selectOption("NOTEBOOK");
|
await page.getByLabel("Typ").selectOption("NOTEBOOK");
|
||||||
await page.getByRole("button", { name: "Gerät registrieren" }).click();
|
await page.getByRole("button", { name: "Gerät registrieren" }).click();
|
||||||
@@ -156,9 +167,15 @@ test.describe("Vom Aufkleber zur Arbeit", () => {
|
|||||||
|
|
||||||
test("trägt am Handy ohne horizontales Scrollen", async ({ page, isMobile }) => {
|
test("trägt am Handy ohne horizontales Scrollen", async ({ page, isMobile }) => {
|
||||||
test.skip(!isMobile, "Nur im Projekt „handy“.");
|
test.skip(!isMobile, "Nur im Projekt „handy“.");
|
||||||
|
/*
|
||||||
|
* Gesucht wird ueber den Tag-Code und nicht ueber „NB-001“: der Name war eine
|
||||||
|
* Annahme ueber den Beispielbestand, und in einer benutzten Datenbank fuehrt
|
||||||
|
* er auf mehr als ein Geraet. Der Code ist eindeutig — die Liste zeigt danach
|
||||||
|
* genau eine Zeile, und der Weg ueber die Liste bleibt der des Technikers.
|
||||||
|
*/
|
||||||
await anmelden(page);
|
await anmelden(page);
|
||||||
await page.goto("/geraete");
|
await page.goto(`/geraete?suche=${SEED_TAG}`);
|
||||||
await page.getByRole("link", { name: "NB-001" }).first().click();
|
await page.locator("ul > li").filter({ hasText: SEED_TAG }).getByRole("link").first().click();
|
||||||
await page.waitForURL(/\/geraete\/[^/]+$/);
|
await page.waitForURL(/\/geraete\/[^/]+$/);
|
||||||
|
|
||||||
const masse = await page.evaluate(() => ({
|
const masse = await page.evaluate(() => ({
|
||||||
@@ -186,7 +203,12 @@ test.afterAll(async () => {
|
|||||||
* Entwicklungsdatenbank ist das derselbe Bestand.
|
* Entwicklungsdatenbank ist das derselbe Bestand.
|
||||||
*/
|
*/
|
||||||
const geraete = await prisma.geraet.findMany({
|
const geraete = await prisma.geraet.findMany({
|
||||||
where: { OR: [{ hostname: { startsWith: "ZZ-E2E-" } }, { tagCode: { in: eigeneTags } }] },
|
/*
|
||||||
|
* Der Praefix gehoert dieser Datei allein. Vorher stand hier `ZZ-E2E-`, und
|
||||||
|
* damit raeumte dieser Nachlauf auch die Geraete aus
|
||||||
|
* `tests/e2e/geraete.spec.ts` weg — fremde Daten, wenn auch Testdaten (#018).
|
||||||
|
*/
|
||||||
|
where: { OR: [{ hostname: { startsWith: HOSTNAME_PRAEFIX } }, { tagCode: { in: eigeneTags } }] },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
const kennungen = geraete.map((geraet) => geraet.id);
|
const kennungen = geraete.map((geraet) => geraet.id);
|
||||||
|
|||||||
@@ -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 { expect, test, type Page } from "@playwright/test";
|
||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
|
import type { Prisma } from "@/generated/prisma/client";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import {
|
||||||
|
SEED_GERAET,
|
||||||
|
SEED_TAG,
|
||||||
|
SEED_VORLAGE,
|
||||||
|
anmelden,
|
||||||
|
kundeWaehlen,
|
||||||
|
variablenFuellen,
|
||||||
|
vorlageWaehlen,
|
||||||
|
} from "./helfer";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ein Lauf von vorn bis hinten durch die Oberflaeche (#013).
|
* Ein Lauf von vorn bis hinten durch die Oberflaeche (#013).
|
||||||
@@ -10,68 +20,57 @@ import { prisma } from "@/lib/db";
|
|||||||
* was nur mit angehaengtem React gelingt, und danach, ob es wirklich in der
|
* was nur mit angehaengtem React gelingt, und danach, ob es wirklich in der
|
||||||
* Datenbank steht: durch Neuladen.
|
* Datenbank steht: durch Neuladen.
|
||||||
*
|
*
|
||||||
* Voraussetzung: ein **gebauter** Stand laeuft (`pnpm build && pnpm start`) und
|
* Voraussetzungen prueft `tests/e2e/global-setup.ts` vor dem ersten Ablauf: ein
|
||||||
* `pnpm beispiel:vorlage` hat die Beispielvorlage angelegt.
|
* **gebauter** Stand antwortet, `SEED_VORLAGE` ist veroeffentlicht, das
|
||||||
|
* Beispielgeraet steht, und der Server liest dieselbe Datenbank wie dieser Lauf.
|
||||||
|
* Gearbeitet wird mit dem Bestand aus `pnpm db:seed` — die Vorlage aus
|
||||||
|
* `pnpm beispiel:vorlage` braucht hier niemand mehr (#018).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const STEMPEL = Date.now();
|
const STEMPEL = Date.now();
|
||||||
const TITEL = `ZZ E2E Lauf ${STEMPEL}`;
|
const TITEL = `ZZ E2E Lauf ${STEMPEL}`;
|
||||||
|
|
||||||
async function anmelden(seite: Page) {
|
/**
|
||||||
const email = process.env["SEED_ADMIN_EMAIL"];
|
* Das Beispielgeraet, geholt ueber den **Tag-Code**.
|
||||||
const passwort = process.env["SEED_ADMIN_PASSWORD"];
|
*
|
||||||
if (!email || !passwort) throw new Error("SEED_ADMIN_EMAIL und SEED_ADMIN_PASSWORD fehlen.");
|
* Nicht ueber `{ index: 1 }` und nicht ueber den Hostnamen: die Position in der
|
||||||
await seite.goto("/anmelden");
|
* Liste haengt an der Sortierung, und „PC-001“ gibt es in einer benutzten
|
||||||
await seite.fill('input[name="email"]', email);
|
* Datenbank mehr als einmal. Der Tag-Code gehoert genau einem Geraet — und dass
|
||||||
await seite.fill('input[name="passwort"]', passwort);
|
* es dieses gibt, prueft der Global-Setup.
|
||||||
await Promise.all([seite.waitForURL("**/"), seite.click('button[type="submit"]')]);
|
*/
|
||||||
|
async function seedGeraetId(): Promise<string> {
|
||||||
|
const geraet = await prisma.geraet.findUniqueOrThrow({
|
||||||
|
where: { tagCode: SEED_TAG },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return geraet.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Die Vorlage aus `pnpm beispiel:vorlage` – diese Ablaeufe arbeiten mit ihr. */
|
|
||||||
const BEISPIEL_VORLAGE = "PC-Neuinstallation (Beispiel)";
|
|
||||||
|
|
||||||
/** Legt einen Lauf an und gibt seine Adresse zurueck. */
|
/** Legt einen Lauf an und gibt seine Adresse zurueck. */
|
||||||
async function laufStarten(seite: Page, titel: string): Promise<string> {
|
async function laufStarten(seite: Page, titel: string): Promise<string> {
|
||||||
await seite.goto("/laeufe/starten");
|
await seite.goto("/laeufe/starten");
|
||||||
|
await vorlageWaehlen(seite, SEED_VORLAGE);
|
||||||
/*
|
/*
|
||||||
* Die Vorlage wird **am Namen** gewaehlt und nicht ueber `index: 1`. Seit dem
|
* Erst der Kunde, dann das Geraet — und zwar **der Kunde des Geraets**. Im
|
||||||
* Seed der sieben Vorlagen (#017) steht dort an erster Stelle, was die
|
* Modus DIENSTLEISTUNG fragt der Dialog danach, und ein Lauf kann strukturell
|
||||||
* Sortierung gerade nach vorn stellt — bei einer frisch eingerichteten
|
* kein Geraet eines fremden Kunden waehlen: der zusammengesetzte
|
||||||
* Installation etwa „Mitarbeiter-Offboarding“ mit Pflichtangaben, die dieser
|
* Fremdschluessel `Lauf_geraet_gleicher_kunde` weist das ab. „Irgendein Kunde“
|
||||||
* Helfer nicht kennt. Der Ablauf lief dann in den Zeitablauf, und die Meldung
|
* waere hier also nicht ungenau, sondern rot. Im Modus INTERN gibt es das Feld
|
||||||
* sagte nichts ueber die Ursache.
|
* nicht.
|
||||||
*/
|
*/
|
||||||
const vorlagen = await seite
|
const geraetId = await seedGeraetId();
|
||||||
.locator('select[name="vorlagenVersionId"] option')
|
const geraet = await prisma.geraet.findUniqueOrThrow({
|
||||||
.evaluateAll((liste) =>
|
where: { id: geraetId },
|
||||||
liste
|
select: { kundeId: true },
|
||||||
.filter((option): option is HTMLOptionElement => option instanceof HTMLOptionElement)
|
});
|
||||||
.map((option) => ({ wert: option.value, text: option.textContent })),
|
await kundeWaehlen(seite, geraet.kundeId);
|
||||||
);
|
// Das Geraet setzt `{{hostname}}` im Befehlsschritt – ohne es bliebe der
|
||||||
const beispiel = vorlagen.find((eintrag) => (eintrag.text ?? "").includes(BEISPIEL_VORLAGE));
|
// Platzhalter stehen, und der Test darauf pruefte nichts.
|
||||||
if (!beispiel) {
|
await seite.selectOption('select[name="geraetId"]', geraetId);
|
||||||
throw new Error(`Die Vorlage „${BEISPIEL_VORLAGE}“ fehlt. Erst „pnpm beispiel:vorlage“ laufen lassen.`);
|
|
||||||
}
|
|
||||||
await seite.selectOption('select[name="vorlagenVersionId"]', beispiel.wert);
|
|
||||||
await seite.selectOption('select[name="geraetId"]', { index: 1 });
|
|
||||||
await seite.locator("details summary", { hasText: "Titel, Ticket" }).click();
|
await seite.locator("details summary", { hasText: "Titel, Ticket" }).click();
|
||||||
await seite.getByLabel("Titel", { exact: true }).fill(titel);
|
await seite.getByLabel("Titel", { exact: true }).fill(titel);
|
||||||
|
|
||||||
// Pflichtvariablen der Vorlage fuellen – welche es sind, weiss nur die Vorlage.
|
// Pflichtvariablen der Vorlage fuellen – welche es sind, weiss nur die Vorlage.
|
||||||
for (const feld of await seite.locator("fieldset select").all()) {
|
await variablenFuellen(seite);
|
||||||
await feld.selectOption({ index: 1 });
|
|
||||||
}
|
|
||||||
for (const feld of await seite.locator('fieldset input[type="number"]').all()) {
|
|
||||||
await feld.fill("16");
|
|
||||||
}
|
|
||||||
for (const feld of await seite.locator('fieldset input[type="date"]').all()) {
|
|
||||||
await feld.fill("2026-10-01");
|
|
||||||
}
|
|
||||||
for (const feld of await seite.locator('fieldset input[type="text"]').all()) {
|
|
||||||
// Nur leere Felder: die Namensvorschlaege sollen stehen bleiben.
|
|
||||||
if ((await feld.inputValue()) === "") await feld.fill("E2E");
|
|
||||||
}
|
|
||||||
|
|
||||||
await seite.getByRole("button", { name: "Lauf anlegen" }).click();
|
await seite.getByRole("button", { name: "Lauf anlegen" }).click();
|
||||||
await seite.waitForURL(/\/laeufe\/(?!starten)[^/]+$/, { timeout: 30_000 });
|
await seite.waitForURL(/\/laeufe\/(?!starten)[^/]+$/, { timeout: 30_000 });
|
||||||
@@ -153,8 +152,11 @@ test.describe("Ein Lauf von vorn bis hinten", () => {
|
|||||||
await laufStarten(page, `${TITEL} B`);
|
await laufStarten(page, `${TITEL} B`);
|
||||||
await page.getByRole("button", { name: "Lauf beginnen" }).click();
|
await page.getByRole("button", { name: "Lauf beginnen" }).click();
|
||||||
|
|
||||||
|
// Am Schritt und nicht ueber `.first()`: welcher Schritt zuerst kommt, ist
|
||||||
|
// eine Annahme ueber die Vorlage (#018).
|
||||||
|
const schrittZuvor = page.getByRole("listitem").filter({ hasText: "Ticket und Endbenutzer" });
|
||||||
// Der Klick allein speichert nicht – erst erscheint das Begründungsfeld.
|
// Der Klick allein speichert nicht – erst erscheint das Begründungsfeld.
|
||||||
await page.getByRole("button", { name: "nicht zutreffend" }).first().click();
|
await schrittZuvor.getByRole("button", { name: "nicht zutreffend" }).click();
|
||||||
const speichern = page.getByRole("button", { name: "Mit Begründung speichern" });
|
const speichern = page.getByRole("button", { name: "Mit Begründung speichern" });
|
||||||
await expect(speichern).toBeVisible();
|
await expect(speichern).toBeVisible();
|
||||||
await expect(speichern).toBeDisabled();
|
await expect(speichern).toBeDisabled();
|
||||||
@@ -211,15 +213,26 @@ test.describe("Ein Lauf von vorn bis hinten", () => {
|
|||||||
await laufStarten(page, `${TITEL} C`);
|
await laufStarten(page, `${TITEL} C`);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Der Befehl **am Schritt**, nicht die Skript-Kopie: die steckt in einem
|
* Am **Schritt**, der den Platzhalter traegt, und nicht am ersten `pre.mono`
|
||||||
* zugeklappten `details` und ist deshalb unsichtbar, bis jemand sie
|
* der Seite: nur dort beweist „enthaelt `{{hostname}}` nicht“ etwas. Ein
|
||||||
* aufklappt.
|
* Befehl ohne Platzhalter waere auch gruen, wenn nie etwas eingesetzt wuerde.
|
||||||
*/
|
*/
|
||||||
const befehl = page.locator("li pre.mono").first();
|
const schritt = page.getByRole("listitem").filter({ hasText: "Hostname gesetzt" });
|
||||||
|
const befehl = schritt.locator("pre.mono").first();
|
||||||
await expect(befehl).toBeVisible();
|
await expect(befehl).toBeVisible();
|
||||||
await expect(befehl).not.toContainText("{{hostname}}");
|
await expect(befehl).not.toContainText("{{hostname}}");
|
||||||
await expect(page.getByRole("button", { name: "Befehl kopieren" }).first()).toBeVisible();
|
// Und was stattdessen dasteht: der Name des gewaehlten Geraets.
|
||||||
await expect(page.getByText("Alle Befehle als Skript kopieren")).toBeVisible();
|
await expect(befehl).toContainText(SEED_GERAET);
|
||||||
|
await expect(schritt.getByRole("button", { name: "Befehl kopieren" })).toBeVisible();
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Die Skript-Kopie steckt in einem zugeklappten `details` und steht **je
|
||||||
|
* Abschnitt**, der Befehle hat — bei dieser Vorlage in dreien. Geprueft wird
|
||||||
|
* deshalb der Abschnitt dieses Schritts, nicht „irgendwo auf der Seite“:
|
||||||
|
* ohne die Eingrenzung fand der Test drei Treffer und brach ab (#018).
|
||||||
|
*/
|
||||||
|
const abschnitt = page.locator("section").filter({ has: schritt });
|
||||||
|
await expect(abschnitt.getByText("Alle Befehle als Skript kopieren")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("trägt am Handy ohne horizontales Scrollen", async ({ page, isMobile }) => {
|
test("trägt am Handy ohne horizontales Scrollen", async ({ page, isMobile }) => {
|
||||||
@@ -285,7 +298,23 @@ test.describe("Namensvorschläge im Start-Dialog", () => {
|
|||||||
*/
|
*/
|
||||||
const VORLAGE = `ZZ E2E Onboarding ${STEMPEL}`;
|
const VORLAGE = `ZZ E2E Onboarding ${STEMPEL}`;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Die Namensregeln des Hauses, wie sie vorher dastanden.
|
||||||
|
*
|
||||||
|
* Sie werden fuer diese beiden Ablaeufe gesetzt und hinterher **zurueckgestellt**
|
||||||
|
* — nicht auf `{}` geleert. Genau das tat der Nachlauf vorher, und damit nahm
|
||||||
|
* er der Datenbank die Regeln, die `pnpm db:seed` eingerichtet hatte: in der
|
||||||
|
* Entwicklungsdatenbank stand danach `namensregeln = {}`, und der Schnellstart
|
||||||
|
* eines Onboardings schlug ohne erkennbaren Grund fehl (#018).
|
||||||
|
*/
|
||||||
|
let namensregelnVorher: Prisma.JsonValue = {};
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
test.beforeAll(async () => {
|
||||||
|
const vorher = await prisma.einstellung.findUniqueOrThrow({
|
||||||
|
where: { id: "global" },
|
||||||
|
select: { namensregeln: true },
|
||||||
|
});
|
||||||
|
namensregelnVorher = vorher.namensregeln;
|
||||||
await prisma.einstellung.update({
|
await prisma.einstellung.update({
|
||||||
where: { id: "global" },
|
where: { id: "global" },
|
||||||
data: {
|
data: {
|
||||||
@@ -318,21 +347,19 @@ test.describe("Namensvorschläge im Start-Dialog", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Waehlt die Testvorlage – ueber den Wert, nicht ueber die Beschriftung. */
|
|
||||||
async function vorlageWaehlen(seite: Page) {
|
|
||||||
const wert = await seite
|
|
||||||
.locator('select[name="vorlagenVersionId"] option', { hasText: VORLAGE })
|
|
||||||
.getAttribute("value");
|
|
||||||
if (!wert) throw new Error(`Die Vorlage „${VORLAGE}“ steht nicht zur Auswahl.`);
|
|
||||||
await seite.selectOption('select[name="vorlagenVersionId"]', wert);
|
|
||||||
}
|
|
||||||
|
|
||||||
test("füllt upn, sam und mail nach den Regeln", async ({ page }) => {
|
test("füllt upn, sam und mail nach den Regeln", async ({ page }) => {
|
||||||
test.slow();
|
test.slow();
|
||||||
await anmelden(page);
|
await anmelden(page);
|
||||||
await page.goto("/laeufe/starten");
|
await page.goto("/laeufe/starten");
|
||||||
await vorlageWaehlen(page);
|
await vorlageWaehlen(page, VORLAGE);
|
||||||
await page.getByLabel("… oder Name als Freitext").fill("Jörg Müller-Lüdenscheid");
|
await page.getByLabel("… oder Name als Freitext").fill("Jörg Müller-Lüdenscheid");
|
||||||
|
/*
|
||||||
|
* Im Modus DIENSTLEISTUNG braucht der Vorschlag einen Kunden: die Regeln
|
||||||
|
* koennen je Kunde eigene sein, und `istVergeben` fragt innerhalb seiner
|
||||||
|
* Grenze. Ohne Auswahl antwortet die Aktion mit „Im Modus Dienstleistung
|
||||||
|
* muss ein Kunde gewählt werden“ und die Felder bleiben leer.
|
||||||
|
*/
|
||||||
|
await kundeWaehlen(page);
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Namen aus den Regeln vorschlagen" }).click();
|
await page.getByRole("button", { name: "Namen aus den Regeln vorschlagen" }).click();
|
||||||
|
|
||||||
@@ -351,8 +378,9 @@ test.describe("Namensvorschläge im Start-Dialog", () => {
|
|||||||
test.slow();
|
test.slow();
|
||||||
await anmelden(page);
|
await anmelden(page);
|
||||||
await page.goto("/laeufe/starten");
|
await page.goto("/laeufe/starten");
|
||||||
await vorlageWaehlen(page);
|
await vorlageWaehlen(page, VORLAGE);
|
||||||
await page.getByLabel("… oder Name als Freitext").fill("Maria Huber");
|
await page.getByLabel("… oder Name als Freitext").fill("Maria Huber");
|
||||||
|
await kundeWaehlen(page);
|
||||||
await page.getByLabel("upn").fill("chefin@firma.de");
|
await page.getByLabel("upn").fill("chefin@firma.de");
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Namen aus den Regeln vorschlagen" }).click();
|
await page.getByRole("button", { name: "Namen aus den Regeln vorschlagen" }).click();
|
||||||
@@ -367,7 +395,10 @@ test.describe("Namensvorschläge im Start-Dialog", () => {
|
|||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
if (vorlage) await prisma.vorlage.delete({ where: { id: vorlage.id } });
|
if (vorlage) await prisma.vorlage.delete({ where: { id: vorlage.id } });
|
||||||
await prisma.einstellung.update({ where: { id: "global" }, data: { namensregeln: {} } });
|
await prisma.einstellung.update({
|
||||||
|
where: { id: "global" },
|
||||||
|
data: { namensregeln: namensregelnVorher as Prisma.InputJsonValue },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 "dotenv/config";
|
||||||
|
import { anmelden, SEED_TAG } from "./helfer";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Der Einstieg ueber den Aufkleber (#014).
|
* Der Einstieg ueber den Aufkleber (#014).
|
||||||
@@ -11,18 +12,12 @@ import "dotenv/config";
|
|||||||
* bringt den Techniker genauso ans Ziel wie der gescannte. Das ist kein
|
* bringt den Techniker genauso ans Ziel wie der gescannte. Das ist kein
|
||||||
* Ersatzweg fuer den Notfall, sondern der Weg, den jeder geht, dessen Browser
|
* Ersatzweg fuer den Notfall, sondern der Weg, den jeder geht, dessen Browser
|
||||||
* keine Kamera freigibt.
|
* keine Kamera freigibt.
|
||||||
|
*
|
||||||
|
* Der Code kommt aus `SEED_TAG` und nicht abgetippt aus dem Kopf: dass es ihn in
|
||||||
|
* dieser Datenbank gibt, prueft `tests/e2e/global-setup.ts` vor dem ersten
|
||||||
|
* Ablauf (#018).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
async function anmelden(seite: Page) {
|
|
||||||
const email = process.env["SEED_ADMIN_EMAIL"];
|
|
||||||
const passwort = process.env["SEED_ADMIN_PASSWORD"];
|
|
||||||
if (!email || !passwort) throw new Error("SEED_ADMIN_EMAIL und SEED_ADMIN_PASSWORD fehlen.");
|
|
||||||
await seite.goto("/anmelden");
|
|
||||||
await seite.fill('input[name="email"]', email);
|
|
||||||
await seite.fill('input[name="passwort"]', passwort);
|
|
||||||
await Promise.all([seite.waitForURL("**/"), seite.click('button[type="submit"]')]);
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe("Scannen", () => {
|
test.describe("Scannen", () => {
|
||||||
test("ist über den Knopf in der Hülle erreichbar", async ({ page }) => {
|
test("ist über den Knopf in der Hülle erreichbar", async ({ page }) => {
|
||||||
await anmelden(page);
|
await anmelden(page);
|
||||||
@@ -36,10 +31,10 @@ test.describe("Scannen", () => {
|
|||||||
await page.goto("/scannen");
|
await page.goto("/scannen");
|
||||||
|
|
||||||
// Ohne Kamera bleibt das Feld – und es nimmt auch Kleinschreibung an.
|
// Ohne Kamera bleibt das Feld – und es nimmt auch Kleinschreibung an.
|
||||||
await page.getByLabel("Code eintippen").fill("lz-7k3f9q");
|
await page.getByLabel("Code eintippen").fill(SEED_TAG.toLowerCase());
|
||||||
await page.getByRole("button", { name: "Öffnen" }).click();
|
await page.getByRole("button", { name: "Öffnen" }).click();
|
||||||
|
|
||||||
await page.waitForURL("**/t/LZ-7K3F9Q");
|
await page.waitForURL(`**/t/${SEED_TAG}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("lässt einen unsinnigen Code gar nicht erst abschicken", async ({ page }) => {
|
test("lässt einen unsinnigen Code gar nicht erst abschicken", async ({ page }) => {
|
||||||
|
|||||||