diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
index 0b2e511..7126b49 100644
--- a/.gitea/workflows/ci.yml
+++ b/.gitea/workflows/ci.yml
@@ -47,6 +47,9 @@ jobs:
- name: Code-Stil prüfen
run: pnpm lint
+ - name: Format prüfen
+ run: pnpm format:pruefen
+
- name: Typen prüfen
run: pnpm typecheck
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 28849a8..8dd4a8b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -57,6 +57,19 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
- JSON-Export und -Import für Vorlagen, Format dokumentiert in `docs/vorlagen-format.md` (#009)
- Zwei Redakteure am selben Entwurf treten sich nicht mehr auf die Füße: wer auf einem überholten
Stand speichert, wird abgewiesen statt die Arbeit des anderen spurlos zu überschreiben (#009)
+- Vorlagenliste mit Suche, Filter nach Kategorie und Schlagwort und dem Kennzeichen
+ „Entwurf vorhanden“; eine Vorlage lässt sich anlegen, duplizieren, archivieren und als JSON
+ ein- und ausspielen (#010)
+- Vorlagen-Editor: Abschnitte, Schritte und Variablen anlegen, bearbeiten, löschen und sortieren
+ — mit dem Finger, der Maus, der Tastatur oder zwei Knöpfen an jeder Zeile (#010)
+- Typabhängige Felder je Schritt: Auswahloptionen, Zahlenbereich mit Einheit, mehrzeiliger Text,
+ Shell und Befehlstext; Hilfetexte als Markdown, dazu ein Doku-Link (#010)
+- Warnung im Editor, wenn ein Platzhalter zu keiner Variablen und zu keinem eingebauten Wert
+ gehört — sie blockiert nichts, sie zeigt den Tippfehler, bevor der Techniker ihn im Lauf findet
+ (#010)
+- Vorschau der Vorlage mit Beispielwerten, so wie ein Lauf sie zeigen wird (#010)
+- Versionsgeschichte je Vorlage: wer wann mit welcher Notiz veröffentlicht hat und wie viele Läufe
+ an einer Version hängen (#010)
### Sicherheit
@@ -94,3 +107,14 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
Aufbau einer Version geben nichts heraus, was der Benutzer nicht sehen darf (#009)
- Der Doku-Link eines Schritts nimmt nur `http` und `https` an; über eine importierte Datei wäre
ein `javascript:`-Link sonst fremder Code im Browser des Technikers (#009)
+- Hilfetexte werden als Markdown ohne rohes HTML gerendert — ein Skript im Hilfetext einer
+ eingespielten Vorlage bleibt sichtbarer Text (#010)
+- Der Doku-Link eines Schritts wird auch in der Vorschau nur dann verlinkt, wenn er `http` oder
+ `https` ist — dort hat ihn das Schema noch nicht gesehen (#010)
+- Die Content-Security-Policy lockert `unsafe-eval` und WebSocket-Verbindungen **nur** im
+ Entwicklungsmodus; ein Test prüft die Entscheidung im Proxy selbst und nicht bloß die Funktion
+ darunter (#010)
+- Eine abgelaufene Anmeldung beendet den Vorgang mit einer Meldung, statt die Seite auszuhängen —
+ im Editor wäre sonst jede ungespeicherte Arbeit verloren gewesen (#010)
+- Eine archivierte Kundenzuordnung bleibt beim Speichern der Kopfdaten erhalten, statt sich
+ stillschweigend in „für alle Kunden“ zu verwandeln (#010)
diff --git a/CLAUDE.md b/CLAUDE.md
index 35ac254..d6cd838 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -149,12 +149,37 @@ createdb -h 127.0.0.1 -p 5434 -U laufzettel laufzettel_review
TEST_DATABASE_URL=postgresql://laufzettel:laufzettel@localhost:5434/laufzettel_review pnpm test
```
+## Oberfläche prüfen
+
+**Immer gegen den gebauten Stand, nie gegen `pnpm dev`:**
+
+```bash
+pnpm build && PORT=3300 APP_URL=http://127.0.0.1:3300 pnpm start
+```
+
+In dieser Umgebung hydriert `next dev` nicht: die Seiten werden serverseitig
+gerendert und sehen vollständig richtig aus, aber React hängt sich nie an — kein
+Knopf, kein Formular, kein Sortieren tut etwas. Nachgewiesen in #010, und zwar
+auch ohne `src/proxy.ts` und ohne `output: "standalone"`; der HMR-WebSocket
+scheitert dort mit `ERR_INVALID_HTTP_RESPONSE`. Es liegt also am Werkzeug, nicht
+an diesem Code — der gebaute Stand hydriert einwandfrei (1281 von 1296 Elementen
+tragen React-Knoten, im Entwicklungsmodus 2).
+
+Das ist heimtückisch: ein Screenshot beweist damit **nichts** über Bedienbarkeit.
+Wer eine Oberfläche abnimmt, klickt sie im gebauten Stand durch — hinzufügen,
+speichern, neu laden, und nachsehen, ob es wirklich in der Datenbank steht.
+`pnpm docs:screenshots` verlangt deshalb ebenfalls den gebauten Stand.
+
## Vor jedem Commit
```bash
-pnpm lint && pnpm typecheck && pnpm test && pnpm pruefe:doku
+pnpm lint && pnpm typecheck && pnpm format:pruefen && pnpm test && pnpm pruefe:doku
```
+`format:pruefen` gehört dazu, seit der Review in #010 darauf gestoßen ist: `lint-staged` formatiert
+beim Commit automatisch nach, und das Ergebnis wäre ein Commit mit Änderungen, die niemand gesehen
+hat. Lieber vorher sehen, was sich ändert.
+
Der Pre-Commit-Hook erzwingt Lint und Format, der Commit-Msg-Hook Conventional Commits mit
höchstens 72 Zeichen im Betreff. Ein Commit pro abgeschlossener, lauffähiger Änderung. Kein
`wip` auf `main`. Fußzeile referenziert das Issue: `Closes #12`.
@@ -166,5 +191,20 @@ und aktive Zustände. Token stehen als CSS-Variablen in `src/app/globals.css` un
Komponenten greifen nur darauf zu, nie auf rohe Hexwerte. Schriften selbst gehostet über
`@fontsource-variable`. Icons ausschließlich Lucide-Stroke-SVGs — **keine Emojis**. Keine
Verläufe, keine Schatten; Karten mit 1-px-Linie. Buttons als Pillen, 40 px hoch, am Handy 44–46 px.
-Abschnitte mit zweistelliger Nummer (`01 · Vorbereitung`). Druckansichten und Etiketten immer hell.
+Abschnitte mit zweistelliger Nummer (`01 · Vorbereitung`), die aus der Position
+kommt und nie gespeichert wird. Druckansichten und Etiketten immer hell.
**Am Handy nie horizontales Scrollen** — bei 390 px geprüft.
+
+> Der folgende Block ist **nicht von uns**: `next dev` schreibt ihn bei jedem Start selbst in diese
+> Datei und stellt ihn wieder her, wenn man ihn löscht. Er steht deshalb hier, auf Englisch, und
+> wird mitcommittet, damit der Arbeitsbaum sauber bleibt.
+
+
+
+# This is NOT the Next.js you know
+
+This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
+
+This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
+
+
diff --git a/docs/architektur.md b/docs/architektur.md
index 8b06075..288a40e 100644
--- a/docs/architektur.md
+++ b/docs/architektur.md
@@ -75,6 +75,16 @@ Editor, den JSON-Export und den Import — drei getrennte Schemata würden ausei
Import wäre der Ort, an dem das erst auffiele. Das Format ist in
[`docs/vorlagen-format.md`](vorlagen-format.md) beschrieben.
+Die **eingebauten Platzhalter** stehen als Liste in [`src/lib/platzhalter.ts`](../src/lib/platzhalter.ts)
+— Daten, kein Code. Der Editor zeigt sie als Hilfe, die Warnung vor unbekannten Platzhaltern
+vergleicht dagegen, und die Auflösung in #012 baut darauf auf. Eine zweite Aufzählung an anderer
+Stelle würde unweigerlich auseinanderlaufen: ein Platzhalter, der in der Hilfe steht, aber nicht
+aufgelöst wird, bliebe im fertigen PowerShell-Befehl als `{{…}}` stehen.
+
+Die Warnung ist ausdrücklich **keine Ablehnung**. Ein Tippfehler soll im Editor auffallen und nicht
+erst dem Techniker im Lauf; es kann aber gute Gründe geben, einen Platzhalter stehen zu lassen —
+etwa weil die Variable gleich noch kommt. Speichern und Veröffentlichen bleiben deshalb erlaubt.
+
## 5. Der Weg zu Phase 4 (Aktionen)
Die in den Vorlagen vorbereiteten PowerShell-Befehle sind bewusst die **Vorstufe ausführbarer
@@ -118,6 +128,28 @@ gegen den Zweck eines Archivs. Der `tagCode` ist zusätzlich in `TagCodeVergabe`
aus dem nie gelöscht wird — sonst würde ein Code nach dem Löschen eines Geräts wieder frei und
ein alter Aufkleber zeigte auf ein fremdes Objekt.
+## 5b. Warum ein Screenshot nichts über Bedienbarkeit sagt
+
+In dieser Umgebung hydriert `next dev` nicht: die Seiten werden serverseitig gerendert und sehen
+vollständig richtig aus, aber React hängt sich nie an — kein Knopf, kein Formular, kein Sortieren
+tut etwas. Nachgewiesen in #010, und zwar auch ohne `src/proxy.ts` und ohne `output: "standalone"`;
+der HMR-WebSocket scheitert mit `ERR_INVALID_HTTP_RESPONSE`. Es liegt am Werkzeug, nicht an diesem
+Code — im gebauten Stand tragen 1281 von 1296 Elementen React-Knoten, im Entwicklungsmodus zwei.
+
+Zwei Folgen, beide verbindlich:
+
+1. **Oberflächen werden gegen `pnpm build && pnpm start` abgenommen**, nicht gegen `pnpm dev` —
+ ebenso die Screenshots. Steht in `CLAUDE.md`.
+2. **Was nur mit angehängtem React gelingt, gehört in einen Playwright-Test.** `pnpm test:e2e`
+ klickt den Editor durch: anlegen, tippen, sortieren mit dem Finger bei 390 px, speichern, neu
+ laden und nachsehen, ob es wirklich in der Datenbank steht.
+
+Die Content-Security-Policy in `src/proxy.ts` ist deshalb zweigeteilt: im Entwicklungsmodus mit
+`unsafe-eval` und WebSocket, im ausgelieferten Stand ohne beides. Die Entscheidung fällt an
+`process.env.NODE_ENV`, und `tests/sicherheitsrichtlinie.test.ts` prüft sie am Proxy selbst — eine
+Prüfung der Funktion mit selbst gesetztem Argument hätte genau den Fehler durchgelassen, vor dem
+der Kommentar darüber warnt.
+
## 6. Betrieb: zwei Wege, eine Regel
Die Datenbank ist auf beiden Wegen von außen nicht erreichbar:
diff --git a/docs/devlog.md b/docs/devlog.md
index b6db790..707f005 100644
--- a/docs/devlog.md
+++ b/docs/devlog.md
@@ -12,11 +12,12 @@ als Unterpunkte mit Aufgabe und Dauer.
| #007 | M2 – Auth, Rollen, Benutzer | 1 h 35 min |
| #008 | M3 – Betriebsmodus und Kunden | 1 h 20 min |
| #009 | M4 – Vorlagen-Versionierung im Service | 1 h 20 min |
-| | **Summe** | **6 h 50 min** |
+| #010 | M5 – Vorlagen-Editor | 2 h 10 min |
+| | **Summe** | **9 h** |
| Meilenstein | Tatsächlich | Status |
|---|---|---|
-| v0.1.0 | 6 h 50 min | in Arbeit |
+| v0.1.0 | 9 h | in Arbeit |
---
@@ -339,3 +340,59 @@ als Unterpunkte mit Aufgabe und Dauer.
- #010 Vorlagen-Editor: Liste mit Suche und Filtern, Abschnitte und Schritte per Drag & Drop,
Vorschau, Veröffentlichen, Versionshistorie, Warnung bei unbekannten Platzhaltern.
+
+---
+
+## 2026-09-25 · Sitzung 6 · 08:20–10:30
+
+**Issue:** #010 (M5)
+**Version:** 0.1.0
+
+### Erledigt
+
+- Vorlagenliste mit Suche und Filtern, Vorlagenseite mit Versionsgeschichte, Kopfdaten, Duplizieren,
+ Archivieren, JSON-Ex- und -Import
+- Vorlagen-Editor (Sub-Agent): Abschnitte, Schritte, Variablen — anlegen, bearbeiten, löschen,
+ sortieren mit Finger, Maus, Tastatur und Knöpfen; typabhängige Felder, Markdown-Hilfetexte,
+ Platzhalter-Warnung, Vorschau mit Beispielwerten
+- `src/lib/platzhalter.ts` als einzige Quelle der eingebauten Platzhalter — #012 baut darauf auf
+- Erste Playwright-Tests: der Editor wird durch die Oberfläche gefahren, einschließlich Sortieren
+ mit dem Finger bei 390 px
+- Unabhängiger Review: zwei Blocker, sechs weitere Befunde, alle abgearbeitet
+- **496 Tests** grün (vorher 428), dazu 5 E2E-Tests
+
+### Entscheidungen
+
+- **Oberflächen werden gegen den gebauten Stand abgenommen, nie gegen `pnpm dev`.** Der
+ Entwicklungsmodus hydriert in dieser Umgebung nicht; ein Screenshot sieht deshalb richtig aus,
+ während kein Knopf etwas tut. Das ändert die Arbeitsweise für alle folgenden Meilensteine und
+ steht in `CLAUDE.md` und `docs/architektur.md`.
+- **Was nur mit angehängtem React gelingt, gehört in einen Playwright-Test.** Nicht erst in #018:
+ ohne ihn wäre „Sortieren funktioniert am Handy“ eine Behauptung geblieben.
+- **Die Nummer eines Abschnitts kommt aus der Position und wird nie gespeichert.** Die
+ Formatdokumentation sagte bisher das Gegenteil; beim ersten Umsortieren wäre eine gespeicherte
+ Nummer falsch gewesen. Entfernt wird eine vorangestellte Nummer nur beim Trenner `·` und nur,
+ wenn dahinter keine Ziffer steht — „24 · 7 Bereitschaft“ bleibt.
+- **Ein Knopf, der auf eine fehlende Seite zeigt, wird nicht verlinkt.** Galt für die Navigation
+ schon, jetzt auch für „Scannen“.
+- **`format:pruefen` ist ein Tor.** Sonst formatiert `lint-staged` beim Commit nach, und im Commit
+ stehen Änderungen, die niemand gesehen hat.
+
+### Probleme
+
+- **Mein eigener Prüfaufbau hat mich getäuscht.** Ich habe Seiten im Browser angesehen, für gut
+ befunden und weitergemacht — in einem Entwicklungsmodus, in dem nichts anklickbar war. Aufgefallen
+ ist es erst, als ich zum ersten Mal wirklich geklickt habe. Zwei Stunden Suche nach der Ursache,
+ am Ende lag es am Werkzeug und nicht am Code; die Erkenntnis war die Zeit wert.
+- **Ein Kommentar hat wieder eine Zusage gemacht, die kein Test hielt** („ein Test hält das fest“,
+ über der CSP). Dritter Meilenstein in Folge, in dem der Review genau diese Sorte Fehler findet.
+ Ein Kommentar, der eine Garantie behauptet, braucht den Test daneben — oder den Satz nicht.
+- **Der Sub-Agent hat im Browser Dinge geprüft, die dort gar nicht prüfbar waren.** Mein Auftrag
+ hat ihn dorthin geschickt; die Lücke war meine.
+- **Der Editor war bei einer einzigen Vorlage 16 986 px hoch.** Ich habe es im Screenshot gesehen,
+ nicht der Review.
+
+### Nächster Schritt
+
+- #011 Mitarbeiter, Abteilungen und Geräte: Listen mit Suche, Mitarbeiterseite mit Historie,
+ Abteilungen mit Ziel-OU und Standardgruppen, Geräte mit CSV-Import.
diff --git a/docs/issues/010-vorlagen-editor.md b/docs/issues/010-vorlagen-editor.md
index aebce8b..f57c33c 100644
--- a/docs/issues/010-vorlagen-editor.md
+++ b/docs/issues/010-vorlagen-editor.md
@@ -1,9 +1,9 @@
# #010 M5 – Vorlagen-Editor
-**Status:** offen
+**Status:** erledigt
**Meilenstein:** v0.1.0
**Labels:** phase-1, frontend
-**Geschätzt:** 10 h **Tatsächlich:** –
+**Geschätzt:** 10 h **Tatsächlich:** 2 h 10 min
## Ziel
@@ -11,21 +11,68 @@ Der Editor, in dem ein Redakteur eine Vorlage baut, ohne den Kopf über Versione
## Aufgaben
-- [ ] Vorlagenliste mit Suche, Filter nach Kategorie und Tag, Kennzeichnung „Entwurf vorhanden“
-- [ ] Editor: Abschnitte und Schritte anlegen, bearbeiten, löschen; Abschnitte mit zweistelliger Nummer (`01 · Vorbereitung`)
-- [ ] Sortieren per Drag & Drop mit `@dnd-kit`, auch am Handy mit dem Finger bedienbar
-- [ ] Je Schritt: Titel, Typ (`HAKEN`, `TEXT`, `ZAHL`, `AUSWAHL`, `DATUM`, `BEFEHL`), Pflicht (roter Stern), Hilfetext als Markdown, Doku-Link
-- [ ] Typabhängige `konfig`-Formulare: Auswahloptionen, Min/Max/Einheit, bei `BEFEHL` die Shell (`powershell`/`bash`/`cmd`), `multiline` und der Befehlstext
-- [ ] Variablen definieren: `key`, Label, Typ, Optionen, Pflicht, Position
-- [ ] Vorschau der Vorlage wie im Lauf, mit Beispielwerten
-- [ ] Veröffentlichen mit Änderungsnotiz, Versionshistorie ansehen, Vorlage duplizieren, JSON-Export und -Import
-- [ ] Warnung im Editor bei unbekannten Platzhaltern in Titel, Hilfetext und Befehlstext
-- [ ] Screenshots Liste, Editor, Historie in `docs/screenshots/m5/`
+- [x] Vorlagenliste mit Suche, Filter nach Kategorie und Tag, Kennzeichnung „Entwurf vorhanden“
+- [x] Editor: Abschnitte und Schritte anlegen, bearbeiten, löschen; Abschnitte mit zweistelliger Nummer (`01 · Vorbereitung`)
+- [x] Sortieren per Drag & Drop mit `@dnd-kit`, auch am Handy mit dem Finger bedienbar
+- [x] Je Schritt: Titel, Typ (`HAKEN`, `TEXT`, `ZAHL`, `AUSWAHL`, `DATUM`, `BEFEHL`), Pflicht (roter Stern), Hilfetext als Markdown, Doku-Link
+- [x] Typabhängige `konfig`-Formulare: Auswahloptionen, Min/Max/Einheit, bei `TEXT` mehrzeilig, bei `BEFEHL` die Shell (`powershell`/`bash`/`cmd`) und der Befehlstext
+- [x] Variablen definieren: `key`, Label, Typ, Optionen, Pflicht, Position
+- [x] Vorschau der Vorlage wie im Lauf, mit Beispielwerten
+- [x] Veröffentlichen mit Änderungsnotiz, Versionshistorie ansehen, Vorlage duplizieren, JSON-Export und -Import
+- [x] Warnung im Editor bei unbekannten Platzhaltern in Titel, Hilfetext und Befehlstext
+- [x] Screenshots Liste, Editor, Historie in `docs/screenshots/m5/`
## Akzeptanzkriterien
-- [ ] Eine Vorlage lässt sich vollständig ohne Umweg über die Datenbank anlegen, sortieren, veröffentlichen
-- [ ] Eine veröffentlichte Version ist im Editor nur lesend; „Bearbeiten“ erzeugt sichtbar einen Entwurf
-- [ ] Unbekannte Platzhalter werden als Warnung angezeigt, blockieren aber das Speichern nicht
-- [ ] Sortieren funktioniert am Handy bei 390 px Breite ohne horizontales Scrollen
-- [ ] Der Markdown-Hilfetext rendert ohne rohes HTML
+- [x] Eine Vorlage lässt sich vollständig ohne Umweg über die Datenbank anlegen, sortieren, veröffentlichen
+- [x] Eine veröffentlichte Version ist im Editor nur lesend; „Bearbeiten“ erzeugt sichtbar einen Entwurf
+- [x] Unbekannte Platzhalter werden als Warnung angezeigt, blockieren aber das Speichern nicht
+- [x] Sortieren funktioniert am Handy bei 390 px Breite ohne horizontales Scrollen
+- [x] Der Markdown-Hilfetext rendert ohne rohes HTML
+
+## Beim Bauen aufgefallen
+
+- **`next dev` hydriert in dieser Umgebung überhaupt nicht.** Die Seiten werden serverseitig
+ gerendert und sehen vollständig richtig aus, aber React hängt sich nie an — kein Knopf tut etwas.
+ Gemessen: im Entwicklungsmodus tragen 2 von 1302 Elementen React-Knoten, im gebauten Stand 1281
+ von 1296. Es liegt nicht an diesem Code: auch ohne `src/proxy.ts` und ohne `output: "standalone"`
+ bleibt es dabei, und der HMR-WebSocket scheitert mit `ERR_INVALID_HTTP_RESPONSE`. Das ist der
+ heimtückischste Befund des Meilensteins, denn **ein Screenshot beweist damit nichts über
+ Bedienbarkeit** — und genau darauf haben sich die Meilensteine #005 bis #009 verlassen. Seitdem
+ steht in `CLAUDE.md`: Oberflächen werden gegen `pnpm build && pnpm start` abgenommen, und was nur
+ mit angehängtem React gelingt, gehört in einen Playwright-Test.
+- **Die CSP hatte im Entwicklungsmodus kein `unsafe-eval`.** React braucht es dort, die Konsole
+ meldete es auf jeder Seite. Behoben — es war nicht die Ursache des Hydrierungsproblems, aber ein
+ echter Fehler. Der ausgelieferte Stand bleibt streng, und der Test dazu prüft jetzt den Proxy
+ selbst statt nur die Funktion darunter: der Review hatte zu Recht beanstandet, dass der
+ Kommentar eine Zusage machte, die kein Test hielt.
+- **Eine abgelaufene Anmeldung hätte im Editor eine Stunde Arbeit vernichtet.** `rechtErforderlich`
+ wirft, der Wurf lief durch die Server Action bis in Nexts Fehlergrenze, und die hängt die Seite
+ aus — samt allem, was noch nicht gespeichert war. Nach zwölf Stunden läuft die Sitzung von selbst
+ ab; es hätte genau die Person getroffen, die lange an einer Vorlage sitzt. Jetzt wird der Fehler
+ gefangen und als Meldung gezeigt, der Aufbau lässt sich bei **jedem** fehlgeschlagenen Speichern
+ als JSON herauskopieren, und es gibt eine deutsche Fehlergrenze.
+- **Eine archivierte Kundenzuordnung verschwand beim Speichern der Kopfdaten.** Der archivierte
+ Kunde fehlte in der Auswahlliste, der Browser wählte die erste Option („für alle Kunden“), und
+ ein Klick auf „Kopfdaten speichern“ machte aus einer kundenspezifischen Vorlage eine für alle
+ sichtbare. Genau das, was der Kommentar über der Unterscheidung „Feld fehlt“ gegen „Feld leer“
+ zu verhindern versprach — er deckte den Fall „Feld da, aber unvollständig“ nicht ab.
+- **Zwei stille Textverfälscher.** `titelOhneNummer` machte aus „24 · 7 Bereitschaft“ ein
+ „7 Bereitschaft“, und `mitBeispielwerten` wertete `$&` im **Ersatztext** als Rückverweis — in
+ #012 wäre daraus ein verfälschter PowerShell-Befehl geworden.
+- **Der Editor war 16 986 px hoch**, weil jeder Schritt dauerhaft aufgeklappt war. Mit den sieben
+ Vorlagen aus #017 (rund 150 Schritte) wäre er unbenutzbar gewesen. Jetzt klappen Schritte,
+ Variablen und Abschnitte zu: dieselbe Vorlage misst 3 482 px am Handy.
+- **`abschnitte.2.schritte.0.titel` ist eine richtige und nutzlose Fehlermeldung.** Daraus wird
+ jetzt „Abschnitt 03, Schritt 1: …“. Ein leeres Pflichtfeld weist weiterhin den ganzen Aufbau ab —
+ aber man findet die Stelle.
+- **Der „Scannen“-Knopf zeigte auf eine Seite, die es nicht gibt.** Next holt Verweise beim
+ Darüberfahren vorab, jede Seite erzeugte also im Hintergrund eine 404. Bis #014 ist er gedämpft
+ und nicht verlinkt — dieselbe Regel, die für die Navigation schon galt.
+- **Eine veröffentlichte Vorlage lässt sich nicht mehr löschen**, auch nicht über einen
+ Kaskadenlöschvorgang: der Trigger aus #009 verweigert es. Beim Aufräumen der Testdaten selbst
+ erlebt. Das ist die Zusage, an der das Archiv hängt — die E2E-Tests archivieren deshalb, statt
+ zu löschen, und brauchen ab #018 eine Wegwerf-Datenbank.
+- **`prettier --check` war rot**, ohne dass ein Tor es bemerkt hätte: `lint-staged` formatiert beim
+ Commit automatisch nach, und das Ergebnis wäre ein Commit mit Änderungen, die niemand gesehen
+ hat. `pnpm format:pruefen` gehört jetzt zu den Toren und zur CI.
diff --git a/docs/issues/README.md b/docs/issues/README.md
index 8ed8400..5053d10 100644
--- a/docs/issues/README.md
+++ b/docs/issues/README.md
@@ -14,7 +14,7 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
| #007 | [M2 – Auth, Rollen, Benutzer](007-auth-rollen-benutzer.md) | erledigt | v0.1.0 | 8 h | 1 h 35 min |
| #008 | [M3 – Betriebsmodus und Kunden](008-betriebsmodus-kunden.md) | erledigt | v0.1.0 | 6 h | 1 h 20 min |
| #009 | [M4 – Vorlagen-Versionierung im Service](009-vorlagen-versionierung.md) | erledigt | v0.1.0 | 6 h | 1 h 20 min |
-| #010 | [M5 – Vorlagen-Editor](010-vorlagen-editor.md) | offen | v0.1.0 | 10 h | – |
+| #010 | [M5 – Vorlagen-Editor](010-vorlagen-editor.md) | erledigt | v0.1.0 | 10 h | 2 h 10 min |
| #011 | [M6 – Mitarbeiter, Abteilungen, Geräte](011-mitarbeiter-abteilungen-geraete.md) | offen | v0.1.0 | 9 h | – |
| #012 | [M7 – Platzhalter, Namensregeln, Escaping](012-platzhalter-namensregeln.md) | offen | v0.1.0 | 8 h | – |
| #013 | [M8 – Läufe: Service und Abarbeitung](013-laeufe-abarbeitung.md) | offen | v0.1.0 | 14 h | – |
@@ -24,4 +24,4 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
| #017 | [M12 – Seed der sieben Vorlagen](017-seed-sieben-vorlagen.md) | offen | v0.1.0 | 8 h | – |
| #018 | [M13 – E2E-Tests, Anleitung, PDF](018-e2e-anleitung-pdf.md) | offen | v0.1.0 | 14 h | – |
| #019 | [M14 – Betriebswege und Release v0.1.0](019-betrieb-release.md) | offen | v0.1.0 | 10 h | – |
-| | **Summe** | | | **136 h** | **6 h 50 min** |
+| | **Summe** | | | **136 h** | **9 h** |
diff --git a/docs/screenshots/m5/admin-benutzer-desktop.png b/docs/screenshots/m5/admin-benutzer-desktop.png
new file mode 100644
index 0000000..58743c6
Binary files /dev/null and b/docs/screenshots/m5/admin-benutzer-desktop.png differ
diff --git a/docs/screenshots/m5/admin-benutzer-handy.png b/docs/screenshots/m5/admin-benutzer-handy.png
new file mode 100644
index 0000000..4f6758f
Binary files /dev/null and b/docs/screenshots/m5/admin-benutzer-handy.png differ
diff --git a/docs/screenshots/m5/admin-einstellungen-desktop.png b/docs/screenshots/m5/admin-einstellungen-desktop.png
new file mode 100644
index 0000000..73d467a
Binary files /dev/null and b/docs/screenshots/m5/admin-einstellungen-desktop.png differ
diff --git a/docs/screenshots/m5/admin-einstellungen-handy.png b/docs/screenshots/m5/admin-einstellungen-handy.png
new file mode 100644
index 0000000..f47021a
Binary files /dev/null and b/docs/screenshots/m5/admin-einstellungen-handy.png differ
diff --git a/docs/screenshots/m5/admin-moduswechsel-desktop.png b/docs/screenshots/m5/admin-moduswechsel-desktop.png
new file mode 100644
index 0000000..08d8178
Binary files /dev/null and b/docs/screenshots/m5/admin-moduswechsel-desktop.png differ
diff --git a/docs/screenshots/m5/admin-moduswechsel-handy.png b/docs/screenshots/m5/admin-moduswechsel-handy.png
new file mode 100644
index 0000000..bb6b4a2
Binary files /dev/null and b/docs/screenshots/m5/admin-moduswechsel-handy.png differ
diff --git a/docs/screenshots/m5/anmelden-desktop.png b/docs/screenshots/m5/anmelden-desktop.png
new file mode 100644
index 0000000..a009bfb
Binary files /dev/null and b/docs/screenshots/m5/anmelden-desktop.png differ
diff --git a/docs/screenshots/m5/anmelden-handy.png b/docs/screenshots/m5/anmelden-handy.png
new file mode 100644
index 0000000..3dd1a9f
Binary files /dev/null and b/docs/screenshots/m5/anmelden-handy.png differ
diff --git a/docs/screenshots/m5/uebersicht-desktop.png b/docs/screenshots/m5/uebersicht-desktop.png
new file mode 100644
index 0000000..c58a5c6
Binary files /dev/null and b/docs/screenshots/m5/uebersicht-desktop.png differ
diff --git a/docs/screenshots/m5/uebersicht-handy.png b/docs/screenshots/m5/uebersicht-handy.png
new file mode 100644
index 0000000..8e4b1db
Binary files /dev/null and b/docs/screenshots/m5/uebersicht-handy.png differ
diff --git a/docs/screenshots/m5/vorlagen-editor-desktop.png b/docs/screenshots/m5/vorlagen-editor-desktop.png
new file mode 100644
index 0000000..87d4522
Binary files /dev/null and b/docs/screenshots/m5/vorlagen-editor-desktop.png differ
diff --git a/docs/screenshots/m5/vorlagen-editor-handy.png b/docs/screenshots/m5/vorlagen-editor-handy.png
new file mode 100644
index 0000000..d18dca8
Binary files /dev/null and b/docs/screenshots/m5/vorlagen-editor-handy.png differ
diff --git a/docs/screenshots/m5/vorlagen-liste-desktop.png b/docs/screenshots/m5/vorlagen-liste-desktop.png
new file mode 100644
index 0000000..3c26b8d
Binary files /dev/null and b/docs/screenshots/m5/vorlagen-liste-desktop.png differ
diff --git a/docs/screenshots/m5/vorlagen-liste-handy.png b/docs/screenshots/m5/vorlagen-liste-handy.png
new file mode 100644
index 0000000..adff965
Binary files /dev/null and b/docs/screenshots/m5/vorlagen-liste-handy.png differ
diff --git a/docs/screenshots/m5/vorlagen-seite-desktop.png b/docs/screenshots/m5/vorlagen-seite-desktop.png
new file mode 100644
index 0000000..d324bf6
Binary files /dev/null and b/docs/screenshots/m5/vorlagen-seite-desktop.png differ
diff --git a/docs/screenshots/m5/vorlagen-seite-handy.png b/docs/screenshots/m5/vorlagen-seite-handy.png
new file mode 100644
index 0000000..7b144eb
Binary files /dev/null and b/docs/screenshots/m5/vorlagen-seite-handy.png differ
diff --git a/docs/vorlagen-format.md b/docs/vorlagen-format.md
index 4d794dc..85e77d5 100644
--- a/docs/vorlagen-format.md
+++ b/docs/vorlagen-format.md
@@ -56,7 +56,7 @@ Platzhaltern. **JSON kennt keine Kommentare** — die Zeilen mit `//` müssen vo
// Die Reihenfolge dieser Liste ist die Reihenfolge in der Ansicht.
"abschnitte": [
{
- "titel": "01 · Vorbereitung",
+ "titel": "Vorbereitung",
"schritte": [
{ "titel": "Gerät auspacken", "pflicht": true },
{
@@ -75,7 +75,7 @@ Platzhaltern. **JSON kennt keine Kommentare** — die Zeilen mit `//` müssen vo
]
},
{
- "titel": "02 · Konto",
+ "titel": "Konto",
"schritte": [
{
"titel": "Benutzerkonto anlegen",
@@ -135,9 +135,17 @@ dass es jemand merkt.
| Feld | Pflicht | Bedeutung |
| ---------- | -------- | ----------------------------------------------------------------------------- |
-| `titel` | Pflicht | Überschrift, nicht leer. Üblich mit zweistelliger Nummer: `01 · Vorbereitung`. |
+| `titel` | Pflicht | Überschrift, nicht leer und **ohne** Nummer — siehe unten. |
| `schritte` | optional | Liste von Schritten in Abarbeitungsreihenfolge; Standard `[]`. |
+Abschnitte werden überall mit einer zweistelligen Nummer angezeigt (`01 · Vorbereitung`). Diese
+Nummer wird **aus der Position abgeleitet und nie gespeichert**. Stünde sie im Titel, wäre sie beim
+ersten Umsortieren falsch — und niemand ginge hinterher durch und zöge sie nach. Trägt ein Titel
+dennoch eine vorangestellte Nummer mit `·` als Trenner (ältere Dateien taten das), wird sie beim
+Öffnen im Editor entfernt, damit nicht `01 · 01 · Vorbereitung` dasteht; mit dem nächsten Speichern
+gilt der bereinigte Titel. Andere Trenner bleiben unangetastet: „2. Etage prüfen“ ist ein Titel und
+keine Nummerierung.
+
### Schritt
| Feld | Pflicht | Bedeutung |
diff --git a/package.json b/package.json
index 23fef70..8de6350 100644
--- a/package.json
+++ b/package.json
@@ -9,6 +9,7 @@
"node": ">=24"
},
"scripts": {
+ "beispiel:vorlage": "tsx scripts/beispiel-vorlage.ts",
"build": "next build",
"db:down": "docker compose -f docker-compose.dev.yml down",
"db:generate": "prisma generate",
@@ -20,6 +21,7 @@
"docs:pdf": "tsx scripts/anleitung-pdf.ts",
"docs:screenshots": "tsx scripts/screenshots.ts",
"format": "prettier --write .",
+ "format:pruefen": "prettier --check .",
"issues:sync": "tsx scripts/gitea-issues.ts",
"lint": "eslint",
"postinstall": "prisma generate",
diff --git a/scripts/beispiel-vorlage.ts b/scripts/beispiel-vorlage.ts
new file mode 100644
index 0000000..ee5e285
--- /dev/null
+++ b/scripts/beispiel-vorlage.ts
@@ -0,0 +1,160 @@
+#!/usr/bin/env tsx
+/**
+ * Eine Beispielvorlage fuer die Screenshots des Editors (#010).
+ *
+ * Der Editor laesst sich nur mit Inhalt zeigen, und die sieben echten Vorlagen
+ * kommen erst mit #017. Bis dahin legt dieses Skript eine Vorlage mit
+ * veroeffentlichter Version 1 **und** einem offenen Entwurf an — also genau die
+ * Lage, die die Oberflaeche unterscheiden muss.
+ *
+ * Es geht durch die echten Dienste aus `src/server/services/vorlagen.ts`, nicht
+ * an ihnen vorbei: waeren die Regeln anders als hier angenommen, schlaegt es
+ * fehl, statt eine Lage herzustellen, die es in der Anwendung nie gibt.
+ *
+ * Idempotent: ein zweiter Lauf legt nichts Neues an.
+ *
+ * pnpm beispiel:vorlage
+ *
+ * Mit #017 wird das Skript geloescht.
+ */
+import "dotenv/config";
+import { prisma } from "../src/lib/db.js";
+import type { AbschnittEingabe, StrukturEingabe, VariableEingabe } from "../src/lib/vorlagen-format.js";
+import {
+ entwurfHolenOderErzeugen,
+ entwurfSpeichern,
+ versionVeroeffentlichen,
+ vorlageAnlegen,
+} from "../src/server/services/vorlagen.js";
+
+const NAME = "PC-Neuinstallation (Beispiel)";
+
+const VARIABLEN: VariableEingabe[] = [
+ {
+ schluessel: "os_version",
+ beschriftung: "Betriebssystem",
+ typ: "AUSWAHL",
+ optionen: ["Windows 11 Pro 24H2", "Windows 11 Enterprise 24H2"],
+ pflicht: true,
+ },
+ {
+ schluessel: "join_typ",
+ beschriftung: "Einbindung",
+ typ: "AUSWAHL",
+ optionen: ["Active Directory", "Entra ID"],
+ pflicht: true,
+ },
+ { schluessel: "arbeitsspeicher", beschriftung: "Arbeitsspeicher in GB", typ: "ZAHL" },
+];
+
+const ABSCHNITTE: AbschnittEingabe[] = [
+ {
+ titel: "Vorbereitung",
+ schritte: [
+ { titel: "Ticket und Endbenutzer im Lauf erfasst", pflicht: true },
+ {
+ titel: "Datensicherung geprüft",
+ hilfetext:
+ "Auch nach Daten **außerhalb** des Profils fragen: Desktop, `D:\\`, lokale Outlook-Archive.\n\n" +
+ "- OneDrive-Status prüfen\n- lokale Archive suchen",
+ pflicht: true,
+ },
+ {
+ titel: "Gerätedaten notiert",
+ typ: "TEXT",
+ hilfetext: "Seriennummer steht auf der Unterseite.",
+ konfig: { mehrzeilig: false },
+ dokuUrl: "https://intern.example/geraetedaten",
+ },
+ ],
+ },
+ {
+ titel: "Installation",
+ schritte: [
+ {
+ titel: "{{os_version}} installiert",
+ pflicht: true,
+ hilfetext: "Vom vorbereiteten Stick starten, keine Datenträgerverschlüsselung aktivieren.",
+ },
+ {
+ titel: "Rechner umbenannt",
+ typ: "BEFEHL",
+ pflicht: true,
+ konfig: {
+ shell: "powershell",
+ befehl: 'Rename-Computer -NewName "{{hostname}}" -Restart',
+ },
+ },
+ {
+ titel: "Arbeitsspeicher geprüft",
+ typ: "ZAHL",
+ konfig: { min: 8, max: 128, einheit: "GB" },
+ },
+ { titel: "Übergabe am {{datum}} bestätigt", typ: "DATUM", pflicht: true },
+ ],
+ },
+];
+
+const VEROEFFENTLICHT: StrukturEingabe = { variablen: VARIABLEN, abschnitte: ABSCHNITTE };
+
+/** Der Entwurf zeigt, wie eine Weiterentwicklung aussieht: ein Abschnitt mehr. */
+const ENTWURF: StrukturEingabe = {
+ variablen: VARIABLEN,
+ abschnitte: [
+ ...ABSCHNITTE,
+ {
+ titel: "Abschluss",
+ schritte: [
+ {
+ titel: "Konto angelegt",
+ typ: "BEFEHL",
+ pflicht: true,
+ konfig: {
+ shell: "powershell",
+ befehl:
+ 'New-ADUser -Name "{{mitarbeiter}}" -UserPrincipalName "{{upn}}" ' +
+ '-SamAccountName "{{sam}}" -Path "{{ou}}" ' +
+ '-AccountPassword (ConvertTo-SecureString "{{generiertes_passwort}}" -AsPlainText -Force) -Enabled $true',
+ },
+ },
+ {
+ titel: "Endgerät übergeben",
+ typ: "AUSWAHL",
+ konfig: { optionen: ["persönlich", "per Post", "über Kollegen"] },
+ },
+ ],
+ },
+ ],
+};
+
+const admin = await prisma.benutzer.findFirst({
+ where: { rolle: "ADMIN" },
+ orderBy: { erstelltAm: "asc" },
+ select: { id: true },
+});
+if (!admin) {
+ throw new Error("Es gibt keinen Administrator. Erst pnpm db:seed laufen lassen.");
+}
+
+const vorhanden = await prisma.vorlage.findFirst({ where: { name: NAME }, select: { id: true } });
+if (vorhanden) {
+ console.log(`„${NAME}“ gibt es schon – nichts zu tun.`);
+} else {
+ const { vorlage, version } = await vorlageAnlegen(
+ {
+ name: NAME,
+ beschreibung: "Neuaufsetzen eines Arbeitsplatzrechners, von der Sicherung bis zur Übergabe.",
+ kategorie: "Client",
+ schlagworte: ["windows", "arbeitsplatz"],
+ },
+ admin.id,
+ );
+ await entwurfSpeichern(version.id, VEROEFFENTLICHT);
+ await versionVeroeffentlichen(version.id, "Erste Fassung nach der Abstimmung im Team.", admin.id);
+
+ const entwurf = await entwurfHolenOderErzeugen(vorlage.id, admin.id);
+ await entwurfSpeichern(entwurf.id, ENTWURF);
+ console.log(`„${NAME}“ angelegt: v1 veröffentlicht, v2 als Entwurf.`);
+}
+
+await prisma.$disconnect();
diff --git a/scripts/screenshots.ts b/scripts/screenshots.ts
index 4097a22..be65711 100644
--- a/scripts/screenshots.ts
+++ b/scripts/screenshots.ts
@@ -10,8 +10,13 @@
* pnpm docs:screenshots --ziel m0 nach docs/screenshots/m0/
* pnpm docs:screenshots --nur uebersicht nur diese Seite
*
- * Voraussetzung: die App laeuft (pnpm start oder pnpm dev). Adresse ueber
- * E2E_APP_URL, Standard http://127.0.0.1:3000.
+ * Voraussetzung: die App laeuft als **gebauter Stand** (pnpm build && pnpm start).
+ * Adresse ueber E2E_APP_URL, Standard http://127.0.0.1:3000.
+ *
+ * Ausdruecklich nicht gegen `pnpm dev`: dort haengt das Entwickler-Overlay im
+ * Bild, und in dieser Umgebung hydriert der Entwicklungsmodus gar nicht — ein
+ * Bild sieht dann richtig aus, zeigt aber eine Seite, auf der kein Knopf etwas
+ * tut. Siehe CLAUDE.md, Abschnitt „Oberflaeche pruefen“.
*/
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
@@ -64,6 +69,35 @@ const SEITEN: readonly Seite[] = [
},
{ name: "admin-benutzer", pfad: "/admin/benutzer" },
{ name: "kunden", pfad: "/kunden", nurModus: "DIENSTLEISTUNG" },
+ { name: "vorlagen-liste", pfad: "/vorlagen" },
+ /*
+ * Vorlagenseite und Editor haengen an Kennungen, die je Datenbank anders sind.
+ * Statt sie zu erraten, geht das Skript den Weg, den auch ein Redakteur geht:
+ * von der Liste aus klicken. Bricht der Weg, bricht der Screenshot — und das
+ * ist die richtige Antwort, denn dann ist die Oberflaeche kaputt.
+ */
+ {
+ name: "vorlagen-seite",
+ pfad: "/vorlagen",
+ vorbereiten: async (tab) => {
+ await tab
+ .getByRole("link", { name: /PC-Neuinstallation/ })
+ .first()
+ .click();
+ await tab.waitForURL(/\/vorlagen\/[^/]+$/);
+ },
+ },
+ {
+ name: "vorlagen-editor",
+ pfad: "/vorlagen",
+ vorbereiten: async (tab) => {
+ await tab
+ .getByRole("link", { name: /Entwurf öffnen/ })
+ .first()
+ .click();
+ await tab.waitForURL(/\/vorlagen\/version\/[^/]+$/);
+ },
+ },
];
function argument(name: string): string | undefined {
diff --git a/src/app/(app)/vorlagen/[id]/page.tsx b/src/app/(app)/vorlagen/[id]/page.tsx
new file mode 100644
index 0000000..aaf57fa
--- /dev/null
+++ b/src/app/(app)/vorlagen/[id]/page.tsx
@@ -0,0 +1,208 @@
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { ArrowLeft, Eye } from "lucide-react";
+import { VorlageKopfdaten } from "@/components/vorlage-kopfdaten";
+import { VorlageBearbeitenKnopf } from "@/components/vorlage-bearbeiten-knopf";
+import {
+ EntwurfVerwerfen,
+ VersionExportieren,
+ VorlageArchivieren,
+ VorlageDuplizieren,
+} from "@/components/vorlage-aktionen";
+import { VersionStatus } from "@/generated/prisma/enums";
+import { darf } from "@/server/auth/rollen";
+import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
+import { kundenEbeneSichtbar } from "@/server/mandant";
+import { kundeHolen, kundenListe } from "@/server/queries/kunden";
+import { versionsHistorie, vorlageHolen, type VersionsZeile } from "@/server/queries/vorlagen";
+
+/**
+ * Eine Vorlage mit ihrer Versionsgeschichte (#010).
+ *
+ * Die Seite beantwortet drei Fragen auf einen Blick: was ist gerade gueltig, wird
+ * gerade daran gearbeitet, und was ist wann veroeffentlicht worden. Der Aufbau
+ * selbst steht im Editor — eine veroeffentlichte Version zeigt er nur lesend.
+ */
+
+const DATUM = new Intl.DateTimeFormat("de-DE", {
+ day: "2-digit",
+ month: "2-digit",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+});
+
+function HistorienZeile({ zeile, darfBearbeiten }: { zeile: VersionsZeile; darfBearbeiten: boolean }) {
+ const istEntwurf = zeile.status === VersionStatus.ENTWURF;
+ return (
+
+ );
+}
+
+/**
+ * Die Kunden fuer das Kopfdaten-Formular — einschliesslich des zugeordneten,
+ * auch wenn er archiviert ist.
+ *
+ * Sonst faellt genau dieser Eintrag aus der Liste, `defaultValue` trifft nichts,
+ * der Browser waehlt die erste Option („für alle Kunden“), und ein Klick auf
+ * „Kopfdaten speichern“ loest die Zuordnung — aus einer Vorlage fuer einen
+ * Kunden wird eine fuer alle. Ein archivierter Kunde ist nicht geloescht; seine
+ * Vorlagen bleiben ihm zugeordnet.
+ */
+async function kundenAuswahl(
+ zugeordnet: string | null,
+ benutzer: Parameters[0],
+): Promise {
+ const aktive = (await kundenListe(benutzer)).map(({ id, name }) => ({ id, name }));
+ if (!zugeordnet || aktive.some((kunde) => kunde.id === zugeordnet)) return aktive;
+
+ const archivierter = await kundeHolen(zugeordnet, benutzer);
+ /*
+ * Sieht der Benutzer den zugeordneten Kunden ueberhaupt nicht, laesst sich
+ * keine richtige Auswahl anbieten. Dann gibt es gar keine: eine leere Liste
+ * bedeutet „kein Feld im Formular“, und ein fehlendes Feld laesst die
+ * Zuordnung unangetastet (siehe actions/vorlagen.ts). Lieber nicht aenderbar
+ * als stillschweigend geloest.
+ */
+ if (!archivierter) return [];
+ return [{ id: archivierter.id, name: `${archivierter.name} (archiviert)` }, ...aktive];
+}
+
+export default async function VorlagenSeite({ params }: { params: Promise<{ id: string }> }) {
+ const { id } = await params;
+ const { benutzer } = await sitzungErforderlich("lesen", `/vorlagen/${id}`);
+ const vorlage = await vorlageHolen(id, benutzer);
+ // Nicht vorhanden und nicht sichtbar sind absichtlich dieselbe Antwort – die
+ // Unterscheidung waere schon eine Auskunft ueber fremde Kunden (#009).
+ if (!vorlage) notFound();
+
+ const darfBearbeiten = darf(benutzer.rolle, "vorlagenBearbeiten");
+ const [historie, kundenEbene] = await Promise.all([
+ versionsHistorie(vorlage.id, benutzer),
+ kundenEbeneSichtbar(),
+ ]);
+ const kunden = kundenEbene && darfBearbeiten ? await kundenAuswahl(vorlage.kundeId, benutzer) : [];
+ const entwurf = historie.find((zeile) => zeile.status === VersionStatus.ENTWURF);
+
+ return (
+ <>
+
+
+ Alle Vorlagen
+
+
+
{vorlage.name}
+
+ {vorlage.veroeffentlichteVersion === null ? (
+ noch keine veröffentlichte Version
+ ) : (
+
+ gültig ist v{vorlage.veroeffentlichteVersion}
+
+ )}
+ {vorlage.kundeName ? · nur {vorlage.kundeName} : null}
+ {vorlage.archiviert ? · archiviert : null}
+
+ {vorlage.beschreibung ? (
+
{vorlage.beschreibung}
+ ) : null}
+
+ {darfBearbeiten ? (
+
+
+ {vorlage.archiviert ? (
+
+ Diese Vorlage ist archiviert. Ein Entwurf lässt sich noch bearbeiten, veröffentlichen lässt er
+ sich erst nach dem Freigeben.
+
+ ) : null}
+
+ ) : null}
+
+
+
Versionen
+
+ Eine veröffentlichte Version wird nie mehr verändert. Läufe halten sich an die Version, mit der sie
+ gestartet wurden.
+
+
+ {historie.map((zeile) => (
+
+ ))}
+
+
+
+ {darfBearbeiten ? (
+ <>
+
+
Kopfdaten
+
+ Name, Kategorie und Schlagworte hängen an der Vorlage, nicht an einer Version — sie lassen sich
+ jederzeit ändern, ohne etwas neu zu veröffentlichen.
+
+
+
+
+
+
Weiteres
+
+
+
+ {entwurf ? : null}
+
+
+ >
+ ) : null}
+ >
+ );
+}
diff --git a/src/app/(app)/vorlagen/page.tsx b/src/app/(app)/vorlagen/page.tsx
new file mode 100644
index 0000000..0ae4a4c
--- /dev/null
+++ b/src/app/(app)/vorlagen/page.tsx
@@ -0,0 +1,230 @@
+import Link from "next/link";
+import { FileText, Pencil, Plus } from "lucide-react";
+import { VorlageAnlegen } from "@/components/vorlage-anlegen";
+import { VorlageImportieren } from "@/components/vorlage-importieren";
+import { darf } from "@/server/auth/rollen";
+import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
+import { kundenEbeneSichtbar } from "@/server/mandant";
+import { kundenListe } from "@/server/queries/kunden";
+import { vorlagenFilterwerte, vorlagenListe, type VorlagenZeile } from "@/server/queries/vorlagen";
+
+/**
+ * Vorlagenliste (#010).
+ *
+ * Die Filter laufen als GET-Formular ueber die Adresse, nicht ueber React-State:
+ * ein gesetzter Filter ist dann verlinkbar, der Zurueck-Knopf tut das Erwartete,
+ * und die Seite bleibt eine Server-Komponente, die nur die sichtbaren Zeilen
+ * ueberhaupt laedt. Die Mandantensicht wenden die Lesemodelle selbst an (#009).
+ */
+
+const FELD =
+ "min-h-11 rounded-karte border border-linie bg-grund px-3 text-sm text-text outline-none focus-visible:border-akzent";
+
+function Kennzeichen({ zeile }: { zeile: VorlagenZeile }) {
+ return (
+
+
+ {/*
+ * Der Entwurf wird direkt verlinkt, wenn es einen gibt. Erst ueber die
+ * Vorlagenseite zu gehen waere ein Klick mehr fuer den haeufigsten Fall:
+ * weiterarbeiten an dem, was man gestern angefangen hat.
+ */}
+ {darfBearbeiten && zeile.entwurfVersionId ? (
+
+
+ Entwurf öffnen
+
+ ) : null}
+
+ Eine Vorlage wird veröffentlicht und ist danach unveränderlich. Ein Lauf hängt immer an der Version,
+ mit der er gestartet wurde — deshalb ändert eine neue Veröffentlichung nie einen laufenden oder
+ archivierten Lauf.
+
+
+
+
+ {zeilen.length === 0 ? (
+
+ Keine Vorlage passt zu diesen Filtern.
+
+ ) : (
+
+ {zeilen.map((zeile) => (
+
+ ))}
+
+ )}
+
+ {darfBearbeiten ? (
+
+
+
+
+ Vorlage anlegen
+
+
+ Die neue Vorlage beginnt als Entwurf. Erst das Veröffentlichen macht sie für Läufe verfügbar.
+
+ ({ id, name }))} />
+
+
+
+
+ Vorlage aus einer Datei einspielen
+
+
+
+
+
+
+ ) : null}
+ >
+ );
+}
diff --git a/src/app/(app)/vorlagen/version/[versionId]/page.tsx b/src/app/(app)/vorlagen/version/[versionId]/page.tsx
new file mode 100644
index 0000000..9ca08fd
--- /dev/null
+++ b/src/app/(app)/vorlagen/version/[versionId]/page.tsx
@@ -0,0 +1,53 @@
+import { notFound } from "next/navigation";
+import { Editor } from "@/components/vorlagen/editor";
+import { VersionStatus } from "@/generated/prisma/enums";
+import { darf } from "@/server/auth/rollen";
+import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
+import { versionMitAufbau } from "@/server/queries/vorlagen";
+
+/**
+ * Eine Version einer Vorlage im Editor (#010).
+ *
+ * Die Seite prueft ihr Recht selbst — die Huelle darunter ist das zweite Netz,
+ * nicht das erste. `lesen` genuegt zum Ansehen; geaendert werden darf nur ein
+ * Entwurf und nur mit `vorlagenBearbeiten`. Beides entscheidet hier der Server,
+ * der `lesend`-Schalter im Editor ist nur die Anzeige davon: die Server Actions
+ * pruefen dasselbe noch einmal.
+ *
+ * `undefined` aus `versionMitAufbau` heisst „gibt es nicht oder darfst du nicht
+ * sehen“ — die Unterscheidung waere schon die Auskunft, die wir nicht geben.
+ */
+export default async function VersionSeite({ params }: { params: Promise<{ versionId: string }> }) {
+ const { versionId } = await params;
+ const { benutzer } = await sitzungErforderlich("lesen", `/vorlagen/version/${versionId}`);
+
+ const version = await versionMitAufbau(versionId, benutzer);
+ if (!version) notFound();
+
+ const darfBearbeiten = darf(benutzer.rolle, "vorlagenBearbeiten");
+ const veroeffentlicht = version.status === VersionStatus.VEROEFFENTLICHT;
+
+ return (
+
+ );
+}
diff --git a/src/app/error.tsx b/src/app/error.tsx
new file mode 100644
index 0000000..3c5457a
--- /dev/null
+++ b/src/app/error.tsx
@@ -0,0 +1,53 @@
+"use client";
+
+import Link from "next/link";
+import { useEffect } from "react";
+
+/**
+ * Die Fehlergrenze der Anwendung – auf Deutsch und mit einem Weg zurueck.
+ *
+ * Ohne sie zeigt Next seine englische Standardseite („This page couldn’t load“),
+ * und der Benutzer steht ohne Hinweis da. Sie ist bewusst die **zweite**
+ * Verteidigungslinie: Aktionen fangen ihre Fehler selbst und geben eine Meldung
+ * zurueck, damit die Seite gar nicht erst ausgehaengt wird und Eingaben stehen
+ * bleiben. Was hier ankommt, ist Unvorhergesehenes.
+ */
+export default function Fehlergrenze({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}) {
+ useEffect(() => {
+ // Der Text des Fehlers gehoert nicht auf den Bildschirm – er kann Interna
+ // enthalten. In die Browserkonsole gehoert er, fuer die Fehlersuche.
+ console.error("Unbehandelter Fehler:", error);
+ }, [error]);
+
+ return (
+
+
Da ist etwas schiefgegangen
+
+ Der Vorgang ließ sich nicht zu Ende bringen. Ein zweiter Versuch hilft oft; bleibt es dabei, hilft der
+ Hinweis unten bei der Fehlersuche.
+
+ {error.digest ?
Kennung: {error.digest}
: null}
+
+
+
+ Zur Übersicht
+
+
+
+ );
+}
diff --git a/src/components/huelle.tsx b/src/components/huelle.tsx
index 58f6856..58dff42 100644
--- a/src/components/huelle.tsx
+++ b/src/components/huelle.tsx
@@ -22,6 +22,9 @@ import { darf } from "@/server/auth/rollen";
import { cn } from "@/lib/utils";
import { versionText } from "@/lib/version";
+/** Wird in #014 auf `true` gestellt, zusammen mit der Seite. */
+const SCANNEN_VERFUEGBAR = false;
+
const SYMBOLE: Record = {
LayoutDashboard,
ListChecks,
@@ -90,15 +93,46 @@ function NavZeile({ eintrag, aktiv }: { eintrag: NavEintrag; aktiv: boolean }) {
);
}
+/**
+ * Der Einstieg des Technikers, an beiden Stellen ganz oben.
+ *
+ * Solange es `/scannen` nicht gibt (#014), wird **nicht** verlinkt: ein Knopf,
+ * der so prominent sitzt, darf nicht auf eine Fehlerseite fuehren. Next holt den
+ * Verweis beim Darueberfahren sogar vorab — jede Seite erzeugte damit eine 404
+ * im Hintergrund. Dieselbe Regel gilt schon fuer die Navigation.
+ */
function ScannenKnopf({ className }: { className?: string }) {
+ // Pille statt der shadcn-Vorgabe rounded-lg, und 44 px am Handy / 40 px am
+ // Schreibtisch statt 32 px – so verlangt es die Gestaltungsvorgabe.
+ const gemeinsam = cn("h-11 gap-2 rounded-pille px-4 text-sm font-semibold md:h-10", className);
+ const inhalt = (
+ <>
+
+ Scannen
+ >
+ );
+
+ if (!SCANNEN_VERFUEGBAR) {
+ // Nicht als abgeblendeter Akzentknopf – das sieht aus wie ein Fehler. Die
+ // gedaempfte Form ist dieselbe, die die Navigation fuer noch fehlende Seiten
+ // benutzt.
+ return (
+
+ {inhalt}
+
+ );
+ }
+
return (
- // Pille statt der shadcn-Vorgabe rounded-lg, und 44 px am Handy / 40 px am
- // Schreibtisch statt 32 px – so verlangt es die Gestaltungsvorgabe.
-