feat(vorlagen): Editor, Liste und Versionsgeschichte
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

Der Redakteur baut eine Vorlage jetzt vollstaendig in der Oberflaeche: Liste mit
Suche und Filtern, Editor mit Abschnitten, Schritten und Variablen, Vorschau mit
Beispielwerten, Veroeffentlichen mit Aenderungsnotiz, Versionsgeschichte.

Sortiert wird mit dem Finger, der Maus, der Tastatur **und** zwei Knoepfen an
jeder Zeile — ein Touch-Drag gelingt auf einem 390-px-Geraet nicht jedem, und
die Reihenfolge ist fachlich zu wichtig fuer nur einen Weg. Schritte, Variablen
und Abschnitte klappen zu: dieselbe Vorlage misst am Handy 3 482 statt 16 986 px.

- src/lib/platzhalter.ts als einzige Quelle der eingebauten Platzhalter; der
  Editor warnt vor unbekannten, blockiert aber nichts
- Hilfetexte als Markdown ohne rohes HTML, Bilder als Alternativtext
- Erste Playwright-Tests: der Editor wird durch die Oberflaeche gefahren

Wichtigster Befund des Meilensteins: `next dev` hydriert in dieser Umgebung
ueberhaupt nicht — die Seiten sehen richtig aus, aber kein Knopf tut etwas
(2 von 1302 Elementen tragen React-Knoten, im gebauten Stand 1281 von 1296).
Nicht an diesem Code: auch ohne proxy.ts und ohne output: standalone. Ein
Screenshot beweist damit nichts ueber Bedienbarkeit. Oberflaechen werden ab
sofort gegen `pnpm build && pnpm start` abgenommen, und was nur mit angehaengtem
React gelingt, gehoert in einen Playwright-Test.

Der unabhaengige Review fand zwei Blocker: eine abgelaufene Anmeldung haette im
Editor jede ungespeicherte Arbeit vernichtet (der Rechtefehler lief bis in Nexts
Fehlergrenze), und das Speichern der Kopfdaten loeste eine archivierte
Kundenzuordnung stillschweigend auf. Dazu sechs weitere Befunde, alle behoben.

496 Tests gruen (vorher 428), dazu 5 E2E-Tests.

Schliesst #010

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-25 10:22:33 +02:00
co-authored by Claude Opus 5
parent d8d5444e9f
commit 548c090562
65 changed files with 5454 additions and 103 deletions
+3
View File
@@ -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
+24
View File
@@ -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)
+42 -2
View File
@@ -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.
<!-- BEGIN:nextjs-agent-rules -->
# 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.
<!-- END:nextjs-agent-rules -->
+32
View File
@@ -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:
+59 -2
View File
@@ -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.
+64 -17
View File
@@ -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.
+2 -2
View File
@@ -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** |
Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 510 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

+11 -3
View File
@@ -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 |
+2
View File
@@ -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",
+160
View File
@@ -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();
+36 -2
View File
@@ -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 {
+208
View File
@@ -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 (
<li className="flex flex-col gap-2 border-t border-linie py-3 first:border-t-0 md:flex-row md:items-start md:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="mono text-sm text-text">v{zeile.version}</span>
{istEntwurf ? (
<span className="rounded-pille bg-akzent-flaeche px-2.5 py-1 text-xs text-akzent">Entwurf</span>
) : (
<span className="text-xs text-nebentext">
veröffentlicht {zeile.veroeffentlichtAm ? DATUM.format(zeile.veroeffentlichtAm) : "–"}
{zeile.veroeffentlichtVon ? ` von ${zeile.veroeffentlichtVon}` : ""}
</span>
)}
<span className="text-xs text-nebentext">
{zeile.anzahlLaeufe} {zeile.anzahlLaeufe === 1 ? "Lauf" : "Läufe"}
</span>
</div>
{zeile.aenderungsnotiz ? (
<p className="mt-1 max-w-prose text-sm text-nebentext">{zeile.aenderungsnotiz}</p>
) : null}
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2">
<Link
href={`/vorlagen/version/${zeile.id}`}
className="flex min-h-11 items-center gap-2 rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10"
>
{istEntwurf ? (
"Im Editor öffnen"
) : (
<>
<Eye className="size-4" strokeWidth={1.75} aria-hidden="true" />
Ansehen
</>
)}
</Link>
{darfBearbeiten ? <VersionExportieren versionId={zeile.id} /> : null}
</div>
</li>
);
}
/**
* 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<typeof kundenListe>[0],
): Promise<readonly { id: string; name: string }[]> {
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 (
<>
<Link
href="/vorlagen"
className="inline-flex min-h-11 items-center gap-2 text-sm text-nebentext hover:text-text"
>
<ArrowLeft className="size-4" strokeWidth={1.75} aria-hidden="true" />
Alle Vorlagen
</Link>
<h1 className="mt-2 text-2xl font-bold">{vorlage.name}</h1>
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm text-nebentext">
{vorlage.veroeffentlichteVersion === null ? (
<span>noch keine veröffentlichte Version</span>
) : (
<span>
gültig ist <span className="mono text-text">v{vorlage.veroeffentlichteVersion}</span>
</span>
)}
{vorlage.kundeName ? <span>· nur {vorlage.kundeName}</span> : null}
{vorlage.archiviert ? <span>· archiviert</span> : null}
</div>
{vorlage.beschreibung ? (
<p className="mt-2 max-w-prose text-nebentext">{vorlage.beschreibung}</p>
) : null}
{darfBearbeiten ? (
<div className="mt-6 flex flex-col gap-3">
<VorlageBearbeitenKnopf vorlageId={vorlage.id} hatEntwurf={vorlage.hatEntwurf} />
{vorlage.archiviert ? (
<p className="max-w-prose text-sm text-faellig">
Diese Vorlage ist archiviert. Ein Entwurf lässt sich noch bearbeiten, veröffentlichen lässt er
sich erst nach dem Freigeben.
</p>
) : null}
</div>
) : null}
<section className="mt-8 rounded-karte border border-linie bg-flaeche p-5">
<h2 className="text-lg font-semibold">Versionen</h2>
<p className="mt-1 max-w-prose text-sm text-nebentext">
Eine veröffentlichte Version wird nie mehr verändert. Läufe halten sich an die Version, mit der sie
gestartet wurden.
</p>
<ul className="mt-4 flex flex-col">
{historie.map((zeile) => (
<HistorienZeile key={zeile.id} zeile={zeile} darfBearbeiten={darfBearbeiten} />
))}
</ul>
</section>
{darfBearbeiten ? (
<>
<section className="mt-4 rounded-karte border border-linie bg-flaeche p-5">
<h2 className="text-lg font-semibold">Kopfdaten</h2>
<p className="mt-1 mb-5 max-w-prose text-sm text-nebentext">
Name, Kategorie und Schlagworte hängen an der Vorlage, nicht an einer Version — sie lassen sich
jederzeit ändern, ohne etwas neu zu veröffentlichen.
</p>
<VorlageKopfdaten
vorlage={{
id: vorlage.id,
name: vorlage.name,
beschreibung: vorlage.beschreibung,
kategorie: vorlage.kategorie,
schlagworte: vorlage.schlagworte,
kundeId: vorlage.kundeId,
}}
kunden={kunden}
/>
</section>
<section className="mt-4 rounded-karte border border-linie bg-flaeche p-5">
<h2 className="text-lg font-semibold">Weiteres</h2>
<div className="mt-4 flex flex-col gap-3">
<VorlageDuplizieren vorlageId={vorlage.id} name={vorlage.name} />
<VorlageArchivieren vorlageId={vorlage.id} archiviert={vorlage.archiviert} />
{entwurf ? <EntwurfVerwerfen versionId={entwurf.id} /> : null}
</div>
</section>
</>
) : null}
</>
);
}
+230
View File
@@ -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 (
<div className="flex flex-wrap items-center gap-2">
{zeile.veroeffentlichteVersion === null ? (
<span className="rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext">
noch nicht veröffentlicht
</span>
) : (
<span className="mono rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext">
v{zeile.veroeffentlichteVersion}
</span>
)}
{zeile.hatEntwurf ? (
<span className="rounded-pille bg-akzent-flaeche px-2.5 py-1 text-xs text-akzent">
Entwurf vorhanden
</span>
) : null}
{zeile.archiviert ? (
<span className="rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext">archiviert</span>
) : null}
{zeile.kundeName ? (
<span className="rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext">
nur {zeile.kundeName}
</span>
) : null}
</div>
);
}
function Zeile({ zeile, darfBearbeiten }: { zeile: VorlagenZeile; darfBearbeiten: boolean }) {
return (
<li className="rounded-karte border border-linie bg-flaeche">
<div className="flex flex-col gap-3 p-4 md:flex-row md:items-start md:justify-between">
<div className="min-w-0">
<Link href={`/vorlagen/${zeile.id}`} className="group flex items-start gap-2">
<FileText
className="mt-0.5 size-[18px] shrink-0 text-nebentext"
strokeWidth={1.75}
aria-hidden="true"
/>
<span
className={`font-medium group-hover:text-akzent ${
zeile.archiviert ? "text-nebentext line-through" : ""
}`}
>
{zeile.name}
</span>
</Link>
{zeile.beschreibung ? (
<p className="mt-1 max-w-prose text-sm text-nebentext">{zeile.beschreibung}</p>
) : null}
<div className="mt-2">
<Kennzeichen zeile={zeile} />
</div>
<p className="mt-2 text-xs text-nebentext">
{[
zeile.kategorie,
...zeile.schlagworte,
`${zeile.anzahlLaeufe} ${zeile.anzahlLaeufe === 1 ? "Lauf" : "Läufe"}`,
]
.filter(Boolean)
.join(" · ")}
</p>
</div>
{/*
* 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 ? (
<Link
href={`/vorlagen/version/${zeile.entwurfVersionId}`}
className="flex min-h-11 shrink-0 items-center gap-2 rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10"
>
<Pencil className="size-4" strokeWidth={1.75} aria-hidden="true" />
Entwurf öffnen
</Link>
) : null}
</div>
</li>
);
}
export default async function VorlagenSeite({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const { benutzer } = await sitzungErforderlich("lesen", "/vorlagen");
const parameter = await searchParams;
const einzeln = (name: string): string =>
(Array.isArray(parameter[name]) ? parameter[name][0] : parameter[name])?.trim() ?? "";
const filter = {
suche: einzeln("suche"),
kategorie: einzeln("kategorie"),
schlagwort: einzeln("schlagwort"),
auchArchivierte: einzeln("archiviert") === "1",
};
const darfBearbeiten = darf(benutzer.rolle, "vorlagenBearbeiten");
const [zeilen, filterwerte, kundenEbene] = await Promise.all([
vorlagenListe(benutzer, filter),
vorlagenFilterwerte(benutzer),
kundenEbeneSichtbar(),
]);
// Die Kundenwahl im Anlegen-Formular gibt es nur im Modus Dienstleistung.
const kunden = kundenEbene ? await kundenListe(benutzer) : [];
return (
<>
<h1 className="text-2xl font-bold">Vorlagen</h1>
<p className="mt-2 max-w-prose text-nebentext">
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.
</p>
<form method="get" className="mt-6 flex flex-col gap-3 md:flex-row md:flex-wrap md:items-end">
<label className="flex min-w-0 flex-1 flex-col gap-1.5 text-sm md:max-w-xs">
<span className="text-nebentext">Suche</span>
<input
name="suche"
defaultValue={filter.suche}
placeholder="Name oder Beschreibung"
className={FELD}
/>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Kategorie</span>
<select name="kategorie" defaultValue={filter.kategorie} className={FELD}>
<option value="">alle</option>
{filterwerte.kategorien.map((kategorie) => (
<option key={kategorie} value={kategorie}>
{kategorie}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Schlagwort</span>
<select name="schlagwort" defaultValue={filter.schlagwort} className={FELD}>
<option value="">alle</option>
{filterwerte.schlagworte.map((wort) => (
<option key={wort} value={wort}>
{wort}
</option>
))}
</select>
</label>
<label className="flex min-h-11 items-center gap-2 text-sm text-nebentext">
<input type="checkbox" name="archiviert" value="1" defaultChecked={filter.auchArchivierte} />
auch archivierte
</label>
<button
type="submit"
className="min-h-11 rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10"
>
Filtern
</button>
{filter.suche || filter.kategorie || filter.schlagwort || filter.auchArchivierte ? (
<Link
href="/vorlagen"
className="flex min-h-11 items-center px-2 text-sm text-nebentext underline underline-offset-2 hover:text-text md:min-h-10"
>
zurücksetzen
</Link>
) : null}
</form>
{zeilen.length === 0 ? (
<p className="mt-8 rounded-karte border border-linie bg-flaeche p-5 text-sm text-nebentext">
Keine Vorlage passt zu diesen Filtern.
</p>
) : (
<ul className="mt-6 flex flex-col gap-3">
{zeilen.map((zeile) => (
<Zeile key={zeile.id} zeile={zeile} darfBearbeiten={darfBearbeiten} />
))}
</ul>
)}
{darfBearbeiten ? (
<div className="mt-8 flex flex-col gap-4">
<section className="rounded-karte border border-linie bg-flaeche p-5">
<h2 className="flex items-center gap-2 text-lg font-semibold">
<Plus className="size-5 text-akzent" strokeWidth={2} aria-hidden="true" />
Vorlage anlegen
</h2>
<p className="mt-1 mb-5 max-w-prose text-sm text-nebentext">
Die neue Vorlage beginnt als Entwurf. Erst das Veröffentlichen macht sie für Läufe verfügbar.
</p>
<VorlageAnlegen kunden={kunden.map(({ id, name }) => ({ id, name }))} />
</section>
<details className="rounded-karte border border-linie bg-flaeche">
<summary className="flex min-h-11 cursor-pointer list-none items-center px-5 text-sm text-nebentext hover:text-text">
Vorlage aus einer Datei einspielen
</summary>
<div className="px-5 pb-5">
<VorlageImportieren />
</div>
</details>
</div>
) : null}
</>
);
}
@@ -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 (
<Editor
version={{
id: version.id,
vorlageId: version.vorlageId,
vorlageName: version.vorlageName,
version: version.version,
veroeffentlicht,
struktur: version.struktur,
stand: version.stand.toISOString(),
aenderungsnotiz: version.aenderungsnotiz,
}}
lesend={veroeffentlicht || !darfBearbeiten}
darfBearbeiten={darfBearbeiten}
// Auf dem Server formatiert: im Browser waere es eine zweite Uhrzeit und
// damit ein Unterschied zwischen Vorrendern und Hydrieren.
beispielDatum={new Date().toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
})}
/>
);
}
+53
View File
@@ -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 (
<main className="flex min-h-dvh flex-col items-center justify-center gap-4 p-6 text-center">
<h1 className="text-2xl font-bold">Da ist etwas schiefgegangen</h1>
<p className="max-w-prose text-nebentext">
Der Vorgang ließ sich nicht zu Ende bringen. Ein zweiter Versuch hilft oft; bleibt es dabei, hilft der
Hinweis unten bei der Fehlersuche.
</p>
{error.digest ? <p className="mono text-xs text-nebentext">Kennung: {error.digest}</p> : null}
<div className="flex flex-wrap items-center justify-center gap-3">
<button
type="button"
onClick={reset}
className="flex min-h-11 items-center rounded-pille bg-akzent px-5 text-sm font-semibold text-auf-akzent md:min-h-10"
>
Noch einmal versuchen
</button>
<Link
href="/"
className="flex min-h-11 items-center rounded-pille border border-linie px-5 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10"
>
Zur Übersicht
</Link>
</div>
</main>
);
}
+41 -7
View File
@@ -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<string, LucideIcon> = {
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 = (
<>
<ScanLine className="size-[18px]" strokeWidth={2} aria-hidden="true" />
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 (
<span
title="Kommt mit #014"
aria-disabled="true"
className={cn(
gemeinsam,
"flex cursor-not-allowed items-center justify-center border border-linie text-nebentext/60",
)}
>
{inhalt}
</span>
);
}
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.
<Button asChild className={cn("h-11 gap-2 rounded-pille px-4 text-sm font-semibold md:h-10", className)}>
<Link href="/scannen">
<ScanLine className="size-[18px]" strokeWidth={2} aria-hidden="true" />
Scannen
</Link>
<Button asChild className={gemeinsam}>
<Link href="/scannen">{inhalt}</Link>
</Button>
);
}
+66
View File
@@ -0,0 +1,66 @@
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
/**
* Hilfetexte als Markdown – ohne rohes HTML.
*
* `react-markdown` laesst rohes HTML von sich aus fallen; `rehype-raw` wuerde es
* wieder einschalten und ist deshalb bewusst nicht installiert. Ein Redakteur
* darf Hilfetexte schreiben, aber kein Skript in die Abarbeitungsansicht
* bringen, die jeder Techniker oeffnet.
*
* Links oeffnen in einem neuen Tab mit `rel="noreferrer"`, damit der Lauf beim
* Klick nicht verlassen wird und die Zielseite nichts ueber die Herkunft erfaehrt.
*/
export function Markdown({ text, className }: { text: string; className?: string }) {
return (
<div className={`markdown text-sm text-nebentext ${className ?? ""}`}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
/*
* `react-markdown` leert den `href`, wenn das Protokoll nicht erlaubt
* ist. Ein `<a href="">` laedt beim Klick die aktuelle Seite in einem
* neuen Tab — verwirrender als gar kein Verweis. Der Text bleibt
* stehen, nur das Anklickbare faellt weg.
*/
a: ({ href, children }) =>
href ? (
<a
href={href}
target="_blank"
rel="noreferrer"
className="text-akzent underline underline-offset-2"
>
{children}
</a>
) : (
<span className="text-nebentext">{children}</span>
),
/*
* Bilder werden **nicht** gerendert, sondern als Alternativtext
* gezeigt. Ein Bild in einem Hilfetext laege auf einem fremden Host,
* und `img-src` erlaubt nur `self`, `data:` und `blob:` — es wuerde
* also ohnehin nie erscheinen, nur ein kaputtes Symbol hinterlassen.
* Anhaenge sind fuer Phase 2 vorgesehen; bis dahin ist der Text
* ehrlicher als ein leerer Rahmen.
*/
img: ({ alt }) => <span className="text-nebentext">[Bild{alt ? `: ${alt}` : ""}]</span>,
code: ({ children }) => (
<code className="mono rounded bg-flaeche-hoch px-1 py-0.5 text-[0.9em]">{children}</code>
),
pre: ({ children }) => (
<pre className="mono overflow-x-auto rounded-karte border border-linie bg-grund p-3 text-xs">
{children}
</pre>
),
ul: ({ children }) => <ul className="list-disc pl-5">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal pl-5">{children}</ol>,
p: ({ children }) => <p className="[&:not(:first-child)]:mt-2">{children}</p>,
}}
>
{text}
</ReactMarkdown>
</div>
);
}
+170
View File
@@ -0,0 +1,170 @@
"use client";
import { useActionState, useState } from "react";
import { Copy, Download, Trash2 } from "lucide-react";
import {
entwurfVerwerfenAktion,
vorlageArchivierenAktion,
vorlageDuplizierenAktion,
vorlageExportierenAktion,
type AktionsErgebnis,
} from "@/server/actions/vorlagen";
// `w-fit`, damit ein Knopf in einem Block-Element nicht auf volle Breite geht –
// drei verschieden breite Balken untereinander sehen aus wie ein Fehler.
const KNOPF =
"flex w-fit min-h-11 items-center gap-2 rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10";
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 Meldung({ ergebnis }: { ergebnis: AktionsErgebnis }) {
if (ergebnis.fehler) {
return (
<p role="alert" className="mt-2 max-w-prose text-sm text-fehler">
{ergebnis.fehler}
</p>
);
}
if (ergebnis.erfolg) {
return (
<p role="status" className="mt-2 max-w-prose text-sm text-erledigt">
{ergebnis.erfolg}
</p>
);
}
return null;
}
/**
* Entwurf verwerfen.
*
* Mit Rueckfrage, weil ein Entwurf nicht protokolliert wird: was hier
* verschwindet, ist wirklich weg. Die veroeffentlichte Version bleibt
* unberuehrt — genau das ist der Sinn des Entwurfs.
*/
export function EntwurfVerwerfen({ versionId }: { versionId: string }) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(entwurfVerwerfenAktion, {});
const [sicher, setSicher] = useState(false);
if (!sicher) {
return (
<button type="button" onClick={() => setSicher(true)} className={KNOPF}>
<Trash2 className="size-4" strokeWidth={1.75} aria-hidden="true" />
Entwurf verwerfen
</button>
);
}
return (
<div className="flex flex-col gap-2">
<p className="max-w-prose text-sm text-faellig">
Der Entwurf wird gelöscht und ist nicht wiederherstellbar. Die veröffentlichte Version bleibt.
</p>
<div className="flex flex-wrap gap-2">
<form action={aktion}>
<input type="hidden" name="versionId" value={versionId} />
<button
type="submit"
disabled={laeuft}
className="flex min-h-11 items-center rounded-pille border border-fehler px-4 text-sm text-fehler hover:bg-fehler/10 md:min-h-10"
>
{laeuft ? "Wird verworfen …" : "Ja, verwerfen"}
</button>
</form>
<button type="button" onClick={() => setSicher(false)} className={KNOPF}>
Abbrechen
</button>
</div>
<Meldung ergebnis={ergebnis} />
</div>
);
}
/** Duplizieren: neue Vorlage, Inhalt der neuesten Version als Entwurf. */
export function VorlageDuplizieren({ vorlageId, name }: { vorlageId: string; name: string }) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(vorlageDuplizierenAktion, {});
return (
<details>
<summary className={`${KNOPF} cursor-pointer list-none`}>
<Copy className="size-4" strokeWidth={1.75} aria-hidden="true" />
Duplizieren
</summary>
<form action={aktion} className="mt-3 flex flex-col gap-3 sm:flex-row sm:items-end">
<input type="hidden" name="vorlageId" value={vorlageId} />
<label className="flex min-w-0 flex-1 flex-col gap-1.5 text-sm">
<span className="text-nebentext">Name der Kopie</span>
<input name="name" required defaultValue={`${name} (Kopie)`} className={FELD} />
</label>
<button type="submit" disabled={laeuft} className={KNOPF}>
{laeuft ? "Wird kopiert …" : "Kopie anlegen"}
</button>
</form>
<Meldung ergebnis={ergebnis} />
</details>
);
}
/** Archivieren statt loeschen – sonst verloere das Archiv seine Bezuege. */
export function VorlageArchivieren({ vorlageId, archiviert }: { vorlageId: string; archiviert: boolean }) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(vorlageArchivierenAktion, {});
return (
<div>
<form action={aktion}>
<input type="hidden" name="vorlageId" value={vorlageId} />
<input type="hidden" name="archiviert" value={archiviert ? "false" : "true"} />
<button type="submit" disabled={laeuft} className={KNOPF}>
{archiviert ? "wieder freigeben" : "archivieren"}
</button>
</form>
<Meldung ergebnis={ergebnis} />
</div>
);
}
/**
* Export einer Version als Datei.
*
* Die Server Action gibt den Text zurueck, und der Browser legt daraus die
* Datei an. Ein Route Handler dafuer waere ein zweiter Weg zu denselben Daten,
* mit eigener Rechtepruefung — und damit eine zweite Stelle, die falsch sein kann.
*/
export function VersionExportieren({ versionId }: { versionId: string }) {
const [laeuft, setLaeuft] = useState(false);
const [fehler, setFehler] = useState<string | null>(null);
async function exportieren() {
setLaeuft(true);
setFehler(null);
try {
const ergebnis = await vorlageExportierenAktion(versionId);
if (!("inhalt" in ergebnis)) {
setFehler(ergebnis.fehler ?? "Der Export ist fehlgeschlagen.");
return;
}
const adresse = URL.createObjectURL(new Blob([ergebnis.inhalt], { type: "application/json" }));
const verweis = document.createElement("a");
verweis.href = adresse;
verweis.download = ergebnis.dateiname;
verweis.click();
URL.revokeObjectURL(adresse);
} finally {
setLaeuft(false);
}
}
return (
<div>
<button type="button" onClick={() => void exportieren()} disabled={laeuft} className={KNOPF}>
<Download className="size-4" strokeWidth={1.75} aria-hidden="true" />
{laeuft ? "Wird erzeugt …" : "Als JSON exportieren"}
</button>
{fehler ? (
<p role="alert" className="mt-2 max-w-prose text-sm text-fehler">
{fehler}
</p>
) : null}
</div>
);
}
+78
View File
@@ -0,0 +1,78 @@
"use client";
import { useActionState } from "react";
import { Button } from "@/components/ui/button";
import { vorlageAnlegenAktion, type AktionsErgebnis } from "@/server/actions/vorlagen";
const FELD =
"min-h-11 rounded-karte border border-linie bg-grund px-3 text-sm text-text outline-none focus-visible:border-akzent";
/**
* Nur die Kopfdaten. Abschnitte, Schritte und Variablen entstehen danach im
* Editor — ein Formular, das alles auf einmal wollte, waere eine Wand aus
* Feldern, bevor ueberhaupt etwas existiert.
*/
export function VorlageAnlegen({ kunden }: { kunden: readonly { id: string; name: string }[] }) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(vorlageAnlegenAktion, {});
return (
<form action={aktion} className="flex flex-col gap-4">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Name</span>
<input name="name" required className={FELD} placeholder="PC-Neuinstallation" />
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Beschreibung (optional)</span>
<textarea name="beschreibung" rows={2} className={`${FELD} py-2`} />
</label>
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Kategorie (optional)</span>
<input name="kategorie" className={FELD} placeholder="Client" />
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Schlagworte, mit Komma getrennt</span>
<input name="schlagworte" className={FELD} placeholder="windows, onboarding" />
</label>
</div>
{/*
* Die Kundenwahl erscheint nur im Modus Dienstleistung – im Modus Intern
* gibt es keine, und der Server verwirft eine mitgeschickte Kennung
* ohnehin (#008).
*/}
{kunden.length > 0 ? (
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Nur für einen Kunden (optional)</span>
<select name="kundeId" defaultValue="" className={FELD}>
<option value="">für alle Kunden</option>
{kunden.map((kunde) => (
<option key={kunde.id} value={kunde.id}>
{kunde.name}
</option>
))}
</select>
</label>
) : null}
{ergebnis.fehler ? (
<p role="alert" className="text-sm text-fehler">
{ergebnis.fehler}
</p>
) : null}
{ergebnis.erfolg ? (
<p role="status" className="text-sm text-erledigt">
{ergebnis.erfolg}
</p>
) : null}
<Button
type="submit"
disabled={laeuft}
className="h-11 self-start rounded-pille px-5 text-sm font-semibold"
>
{laeuft ? "Wird angelegt …" : "Vorlage anlegen"}
</Button>
</form>
);
}
@@ -0,0 +1,63 @@
"use client";
import { useActionState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Pencil } from "lucide-react";
import { Button } from "@/components/ui/button";
import { entwurfOeffnenAktion, type AktionsErgebnis } from "@/server/actions/vorlagen";
/**
* „Bearbeiten“ – der wichtigste Knopf der Vorlagenseite.
*
* Er legt keinen Text um, sondern erzeugt eine **tiefe Kopie** der letzten
* veroeffentlichten Version als neuen Entwurf (#009) und fuehrt dann in den
* Editor. Gibt es schon einen Entwurf, bekommt man genau diesen — auch wenn zwei
* Redakteure gleichzeitig klicken.
*
* Als Client-Komponente, weil die Server Action die Kennung des Entwurfs
* zurueckgibt und erst danach klar ist, wohin es geht.
*/
export function VorlageBearbeitenKnopf({
vorlageId,
hatEntwurf,
}: {
vorlageId: string;
hatEntwurf: boolean;
}) {
const router = useRouter();
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis & { versionId?: string }, FormData>(
entwurfOeffnenAktion,
{},
);
useEffect(() => {
if (ergebnis.versionId) {
router.push(`/vorlagen/version/${ergebnis.versionId}`);
}
}, [ergebnis.versionId, router]);
return (
<div>
<form action={aktion}>
<input type="hidden" name="vorlageId" value={vorlageId} />
<Button
type="submit"
disabled={laeuft || ergebnis.versionId !== undefined}
className="h-11 gap-2 rounded-pille px-5 text-sm font-semibold md:h-10"
>
<Pencil className="size-4" strokeWidth={2} aria-hidden="true" />
{laeuft || ergebnis.versionId
? "Editor wird geöffnet …"
: hatEntwurf
? "Entwurf weiter bearbeiten"
: "Bearbeiten"}
</Button>
</form>
{ergebnis.fehler ? (
<p role="alert" className="mt-2 max-w-prose text-sm text-fehler">
{ergebnis.fehler}
</p>
) : null}
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
"use client";
import { useActionState, useState } from "react";
import { Button } from "@/components/ui/button";
import { vorlageImportierenAktion, type AktionsErgebnis } from "@/server/actions/vorlagen";
/**
* Einspielen eines Austauschdokuments.
*
* Die Datei wird **im Browser** gelesen und ihr Inhalt als Text mitgeschickt,
* statt sie als Datei-Upload durch die Server Action zu schleusen. Das Format
* ist Text, die Datei ist klein, und so bleibt die Action dieselbe wie fuer
* eingefuegten Inhalt — ein Weg statt zwei.
*
* Eingespielt wird immer eine **neue** Vorlage als Entwurf, nie in eine
* bestehende hinein (docs/vorlagen-format.md).
*/
export function VorlageImportieren() {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(vorlageImportierenAktion, {});
const [inhalt, setInhalt] = useState("");
const [leseFehler, setLeseFehler] = useState<string | null>(null);
async function dateiLesen(datei: File | undefined) {
if (!datei) return;
setLeseFehler(null);
try {
setInhalt(await datei.text());
} catch {
setLeseFehler("Die Datei ließ sich nicht lesen.");
}
}
return (
<form action={aktion} className="flex flex-col gap-4">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Vorlagendatei (.json)</span>
<input
type="file"
accept="application/json,.json"
onChange={(ereignis) => void dateiLesen(ereignis.target.files?.[0])}
className="min-h-11 rounded-karte border border-linie bg-grund px-3 py-2 text-sm text-nebentext file:mr-3 file:rounded-pille file:border-0 file:bg-flaeche-hoch file:px-3 file:py-1.5 file:text-sm file:text-text"
/>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">… oder Inhalt einfügen</span>
<textarea
name="dokument"
rows={6}
required
value={inhalt}
onChange={(ereignis) => setInhalt(ereignis.target.value)}
className="mono rounded-karte border border-linie bg-grund px-3 py-2 text-xs text-text outline-none focus-visible:border-akzent"
placeholder={'{ "format": 1, "name": "…", "struktur": { … } }'}
/>
</label>
{leseFehler ? (
<p role="alert" className="text-sm text-fehler">
{leseFehler}
</p>
) : null}
{ergebnis.fehler ? (
<p role="alert" className="text-sm text-fehler">
{ergebnis.fehler}
</p>
) : null}
{ergebnis.erfolg ? (
<p role="status" className="text-sm text-erledigt">
{ergebnis.erfolg}
</p>
) : null}
<Button
type="submit"
disabled={laeuft}
className="h-11 self-start rounded-pille px-5 text-sm font-semibold"
>
{laeuft ? "Wird eingespielt …" : "Einspielen"}
</Button>
</form>
);
}
+94
View File
@@ -0,0 +1,94 @@
"use client";
import { useActionState } from "react";
import { Button } from "@/components/ui/button";
import { vorlageBearbeitenAktion, type AktionsErgebnis } from "@/server/actions/vorlagen";
const FELD =
"min-h-11 rounded-karte border border-linie bg-grund px-3 text-sm text-text outline-none focus-visible:border-akzent";
/**
* Kopfdaten einer Vorlage: Name, Beschreibung, Kategorie, Schlagworte, Kunde.
*
* Sie haengen an der Vorlage, nicht an einer Version — deshalb lassen sie sich
* jederzeit aendern, auch wenn die neueste Version veroeffentlicht ist. Ein
* Tippfehler im Namen soll keine neue Version kosten, und die Schritte, auf die
* ein Lauf verweist, bleiben davon unberuehrt.
*/
export function VorlageKopfdaten({
vorlage,
kunden,
}: {
vorlage: {
readonly id: string;
readonly name: string;
readonly beschreibung: string | null;
readonly kategorie: string | null;
readonly schlagworte: readonly string[];
readonly kundeId: string | null;
};
kunden: readonly { id: string; name: string }[];
}) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(vorlageBearbeitenAktion, {});
return (
<form action={aktion} className="flex flex-col gap-4">
<input type="hidden" name="vorlageId" value={vorlage.id} />
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Name</span>
<input name="name" required defaultValue={vorlage.name} className={FELD} />
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Beschreibung</span>
<textarea
name="beschreibung"
rows={2}
defaultValue={vorlage.beschreibung ?? ""}
className={`${FELD} py-2`}
/>
</label>
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Kategorie</span>
<input name="kategorie" defaultValue={vorlage.kategorie ?? ""} className={FELD} />
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Schlagworte, mit Komma getrennt</span>
<input name="schlagworte" defaultValue={vorlage.schlagworte.join(", ")} className={FELD} />
</label>
</div>
{kunden.length > 0 ? (
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Nur für einen Kunden</span>
<select name="kundeId" defaultValue={vorlage.kundeId ?? ""} className={FELD}>
<option value="">für alle Kunden</option>
{kunden.map((kunde) => (
<option key={kunde.id} value={kunde.id}>
{kunde.name}
</option>
))}
</select>
</label>
) : null}
{ergebnis.fehler ? (
<p role="alert" className="text-sm text-fehler">
{ergebnis.fehler}
</p>
) : null}
{ergebnis.erfolg ? (
<p role="status" className="text-sm text-erledigt">
{ergebnis.erfolg}
</p>
) : null}
<Button
type="submit"
disabled={laeuft}
className="h-11 self-start rounded-pille px-5 text-sm font-semibold"
>
{laeuft ? "Wird gespeichert …" : "Kopfdaten speichern"}
</Button>
</form>
);
}
@@ -0,0 +1,149 @@
"use client";
import { useState } from "react";
import { KlappKarte, LoeschKnopf, TextKnopf, Textfeld } from "@/components/vorlagen/editor-felder";
import { SortierBereich, SortierZeile, VerschiebeKnoepfe } from "@/components/vorlagen/editor-sortieren";
import { SchrittKarte } from "@/components/vorlagen/editor-schritt";
import { abschnittNummer, type EditorAbschnitt, type EditorSchritt } from "@/lib/editor-aufbau";
import { GRENZEN } from "@/lib/vorlagen-format";
/**
* Ein Abschnitt mit seinen Schritten (#010).
*
* Die Nummer links steht fuer die Position und wird nie gespeichert — wer den
* Abschnitt verschiebt, bekommt sofort die neue Nummer zu sehen.
*
* Aufgeklappt als Standard, anders als Schritte und Variablen: man arbeitet in
* aller Regel in einem Abschnitt und will seine Schritte sehen. Zugeklappt steht
* die Zahl der Schritte daneben, damit beim Umsortieren klar ist, was da
* gerade wandert.
*
* Schritte werden innerhalb ihres Abschnitts sortiert; jeder Abschnitt hat dazu
* seinen eigenen Sortierbereich. Ein Schritt laesst sich damit nicht in einen
* anderen Abschnitt ziehen — das braucht die Vorlagenpflege nicht, und ein
* Bereich ueber alle Abschnitte hinweg haette am Handy Ziehwege von mehreren
* Bildschirmhoehen.
*/
export function AbschnittKarte({
abschnitt,
index,
anzahl,
lesend,
zuletztAngelegt,
anfasser,
aendern,
loeschen,
aufSortiert,
schrittAendern,
schrittHinzufuegen,
schrittLoeschen,
schritteUmsortieren,
}: {
abschnitt: EditorAbschnitt;
index: number;
anzahl: number;
lesend: boolean;
/** Kennung der zuletzt angelegten Zeile — die wird aufgeklappt gezeigt. */
zuletztAngelegt: string | null;
anfasser: React.ReactNode;
aendern: (teil: Partial<EditorAbschnitt>) => void;
loeschen: () => void;
aufSortiert: (von: number, nach: number) => void;
schrittAendern: (kennung: string, teil: Partial<EditorSchritt>) => void;
schrittHinzufuegen: () => void;
schrittLoeschen: (kennung: string) => void;
schritteUmsortieren: (von: number, nach: number) => void;
}) {
const [offen, setzeOffen] = useState(true);
const voll = abschnitt.schritte.length >= GRENZEN.schritteJeAbschnitt;
const anzahlSchritte = abschnitt.schritte.length;
return (
<KlappKarte
offen={offen}
umschalten={() => setzeOffen((vorher) => !vorher)}
flaeche="flaeche"
ueberschrift
anfasser={anfasser}
kopf={
<>
<span className="zahl shrink-0 text-lg text-akzent">{abschnittNummer(index)}</span>
<span className="shrink-0 text-nebentext">·</span>
<span className={`truncate font-semibold ${abschnitt.titel.trim() === "" ? "text-nebentext" : ""}`}>
{abschnitt.titel.trim() === "" ? "ohne Titel" : abschnitt.titel}
</span>
<span className="shrink-0 text-xs font-normal text-nebentext">
{anzahlSchritte} {anzahlSchritte === 1 ? "Schritt" : "Schritte"}
</span>
</>
}
knoepfe={
lesend ? null : (
<>
<VerschiebeKnoepfe art="Abschnitt" index={index} anzahl={anzahl} aufSortiert={aufSortiert} />
<LoeschKnopf
beschriftung="Abschnitt löschen"
{...(anzahlSchritte > 0
? {
frage: `Abschnitt mit ${anzahlSchritte} ${
anzahlSchritte === 1 ? "Schritt" : "Schritten"
} löschen?`,
}
: {})}
aufLoeschen={loeschen}
/>
</>
)
}
>
{lesend ? null : (
<Textfeld
beschriftung="Titel des Abschnitts"
wert={abschnitt.titel}
setzen={(titel) => aendern({ titel })}
lesend={lesend}
vorschlag="Vorbereitung"
/>
)}
{anzahlSchritte === 0 ? (
<p className="text-sm text-nebentext">Noch kein Schritt in diesem Abschnitt.</p>
) : (
<SortierBereich
kennungen={abschnitt.schritte.map((schritt) => schritt.kennung)}
art="Schritt"
lesend={lesend}
aufSortiert={schritteUmsortieren}
>
<ul className="flex flex-col gap-3">
{abschnitt.schritte.map((schritt, stelle) => (
<SortierZeile key={schritt.kennung} kennung={schritt.kennung} lesend={lesend}>
{(schrittAnfasser) => (
<SchrittKarte
schritt={schritt}
index={stelle}
anzahl={anzahlSchritte}
lesend={lesend}
neu={schritt.kennung === zuletztAngelegt}
anfasser={schrittAnfasser}
aendern={(teil) => schrittAendern(schritt.kennung, teil)}
loeschen={() => schrittLoeschen(schritt.kennung)}
aufSortiert={schritteUmsortieren}
/>
)}
</SortierZeile>
))}
</ul>
</SortierBereich>
)}
{lesend ? null : (
<TextKnopf
beschriftung={voll ? `Höchstens ${GRENZEN.schritteJeAbschnitt} Schritte` : "Schritt hinzufügen"}
aus={voll}
aufKlick={schrittHinzufuegen}
/>
)}
</KlappKarte>
);
}
+460
View File
@@ -0,0 +1,460 @@
"use client";
import { useId, useState } from "react";
import { ChevronDown, Plus, Trash2, type LucideIcon } from "lucide-react";
import { GRENZEN } from "@/lib/vorlagen-format";
/**
* Die kleinen Bauteile des Vorlagen-Editors (#010).
*
* Jedes traegt einen `lesend`-Schalter statt einer zweiten, nur lesenden
* Fassung: eine veroeffentlichte Version wird mit denselben Bauteilen gezeigt
* wie ein Entwurf. Zwei Codepfade waeren zwei Gelegenheiten, dass die Ansicht
* etwas anderes zeigt als der Editor.
*
* Eingabefelder werden im Lesemodus `readOnly` und nicht `disabled`: der Inhalt
* bleibt markierbar, und ein Befehlstext laesst sich herauskopieren. Auswahl und
* Kontrollkasten kennen kein `readOnly`, die werden `disabled`.
*/
export const FELD =
"min-h-11 w-full rounded-karte border border-linie bg-grund px-3 text-sm text-text outline-none focus-visible:border-akzent read-only:text-nebentext";
const FLAECHE_FELD = `${FELD} py-2`;
/** Knopf mit Symbol, 44 px am Handy. Beschriftung nur fuer Hilfsmittel. */
export function SymbolKnopf({
symbol: Symbol,
beschriftung,
aufKlick,
aus,
ton,
}: {
symbol: LucideIcon;
beschriftung: string;
aufKlick: () => void;
aus?: boolean;
ton?: "fehler";
}) {
return (
<button
type="button"
onClick={aufKlick}
disabled={aus}
title={beschriftung}
aria-label={beschriftung}
className={`flex size-11 shrink-0 items-center justify-center rounded-pille border border-linie md:size-10 ${
ton === "fehler"
? "text-nebentext hover:bg-flaeche-hoch hover:text-fehler"
: "text-nebentext hover:bg-flaeche-hoch hover:text-text"
} disabled:pointer-events-none disabled:opacity-40`}
>
<Symbol className="size-[18px]" strokeWidth={1.75} aria-hidden="true" />
</button>
);
}
/** Flacher Knopf mit Text, fuer „hinzufügen“. */
export function TextKnopf({
beschriftung,
aufKlick,
aus,
symbol: Symbol = Plus,
}: {
beschriftung: string;
aufKlick: () => void;
aus?: boolean;
symbol?: LucideIcon;
}) {
return (
<button
type="button"
onClick={aufKlick}
disabled={aus}
className="flex min-h-11 items-center gap-2 self-start rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text disabled:pointer-events-none disabled:opacity-40 md:min-h-10"
>
<Symbol className="size-[18px]" strokeWidth={1.75} aria-hidden="true" />
{beschriftung}
</button>
);
}
/**
* Loeschen mit Rueckfrage an der Stelle, an der geklickt wurde.
*
* Die Rueckfrage nur dort, wo mehr als eine Zeile verloren geht — einen leeren
* Abschnitt oder eine Option legt man in zwei Sekunden neu an, und eine
* Rueckfrage, die man immer wegklickt, wird nicht gelesen.
*/
export function LoeschKnopf({
beschriftung,
frage,
aufLoeschen,
}: {
beschriftung: string;
frage?: string;
aufLoeschen: () => void;
}) {
const [fragt, setzeFragt] = useState(false);
if (frage && fragt) {
return (
<div className="flex flex-wrap items-center justify-end gap-2">
<span className="text-xs text-faellig">{frage}</span>
<button
type="button"
onClick={aufLoeschen}
className="min-h-11 rounded-pille border border-linie px-3 text-sm text-fehler hover:bg-flaeche-hoch md:min-h-10"
>
Löschen
</button>
<button
type="button"
onClick={() => setzeFragt(false)}
className="min-h-11 rounded-pille px-3 text-sm text-nebentext hover:text-text md:min-h-10"
>
Abbrechen
</button>
</div>
);
}
return (
<SymbolKnopf
symbol={Trash2}
beschriftung={beschriftung}
ton="fehler"
aufKlick={() => (frage ? setzeFragt(true) : aufLoeschen())}
/>
);
}
export function Textfeld({
beschriftung,
wert,
setzen,
lesend,
hinweis,
fehler,
mono,
vorschlag,
maxLaenge = GRENZEN.text,
versteckteBeschriftung,
}: {
beschriftung: string;
wert: string;
setzen: (wert: string) => void;
lesend: boolean;
hinweis?: string;
fehler?: string;
mono?: boolean;
vorschlag?: string;
maxLaenge?: number;
/** Fuer Felder, deren Bedeutung aus dem Zusammenhang klar ist — etwa der Titel in einer Zeile. */
versteckteBeschriftung?: boolean;
}) {
return (
<label className="flex min-w-0 flex-1 flex-col gap-1.5 text-sm">
<span className={versteckteBeschriftung ? "sr-only" : "text-nebentext"}>{beschriftung}</span>
<input
value={wert}
onChange={(ereignis) => setzen(ereignis.target.value)}
readOnly={lesend}
maxLength={maxLaenge}
// Kein Vorschlagstext im Lesemodus: ein grauer Beispielwert in einem
// Feld, das niemand fuellen kann, sieht aus wie ein Inhalt.
placeholder={lesend ? undefined : vorschlag}
aria-invalid={fehler ? true : undefined}
className={`${FELD} ${mono ? "mono" : ""} ${fehler ? "border-fehler" : ""}`}
/>
{fehler ? (
<span role="alert" className="text-xs text-fehler">
{fehler}
</span>
) : hinweis ? (
<span className="text-xs text-nebentext">{hinweis}</span>
) : null}
</label>
);
}
export function Flaechenfeld({
beschriftung,
wert,
setzen,
lesend,
hinweis,
zeilen = 3,
mono,
vorschlag,
}: {
beschriftung: string;
wert: string;
setzen: (wert: string) => void;
lesend: boolean;
hinweis?: string;
zeilen?: number;
mono?: boolean;
vorschlag?: string;
}) {
return (
<label className="flex min-w-0 flex-col gap-1.5 text-sm">
<span className="text-nebentext">{beschriftung}</span>
<textarea
value={wert}
onChange={(ereignis) => setzen(ereignis.target.value)}
readOnly={lesend}
rows={zeilen}
maxLength={GRENZEN.text}
placeholder={lesend ? undefined : vorschlag}
className={`${FLAECHE_FELD} ${mono ? "mono" : ""}`}
/>
{hinweis ? <span className="text-xs text-nebentext">{hinweis}</span> : null}
</label>
);
}
export function Auswahlfeld<T extends string>({
beschriftung,
wert,
werte,
setzen,
lesend,
beschriftungen,
}: {
beschriftung: string;
wert: T;
werte: readonly T[];
setzen: (wert: T) => void;
lesend: boolean;
beschriftungen?: Readonly<Record<string, string>>;
}) {
return (
<label className="flex min-w-0 flex-col gap-1.5 text-sm">
<span className="text-nebentext">{beschriftung}</span>
<select
value={wert}
onChange={(ereignis) => setzen(ereignis.target.value as T)}
disabled={lesend}
className={`${FELD} disabled:text-nebentext`}
>
{werte.map((eintrag) => (
<option key={eintrag} value={eintrag}>
{beschriftungen?.[eintrag] ?? eintrag}
</option>
))}
</select>
</label>
);
}
export function Kontrollkasten({
beschriftung,
wert,
setzen,
lesend,
}: {
beschriftung: string;
wert: boolean;
setzen: (wert: boolean) => void;
lesend: boolean;
}) {
return (
<label className="flex min-h-11 shrink-0 cursor-pointer items-center gap-2 text-sm md:min-h-10">
<input
type="checkbox"
checked={wert}
onChange={(ereignis) => setzen(ereignis.target.checked)}
disabled={lesend}
className="size-5 accent-akzent"
/>
<span className="text-nebentext">{beschriftung}</span>
</label>
);
}
/** Zahlfeld fuer `min` und `max`: leeres Feld heisst „keine Grenze“, nicht „null“. */
export function Zahlfeld({
beschriftung,
wert,
setzen,
lesend,
}: {
beschriftung: string;
wert: number | undefined;
setzen: (wert: number | undefined) => void;
lesend: boolean;
}) {
return (
<label className="flex min-w-0 flex-col gap-1.5 text-sm">
<span className="text-nebentext">{beschriftung}</span>
<input
type="number"
value={wert ?? ""}
onChange={(ereignis) => {
const roh = ereignis.target.value;
const zahl = Number(roh);
setzen(roh === "" || !Number.isFinite(zahl) ? undefined : zahl);
}}
readOnly={lesend}
className={FELD}
/>
</label>
);
}
/**
* Die Optionen einer Auswahl — bei einem Schritt wie bei einer Variablen.
*
* Die Obergrenze steht am Knopf, bevor jemand dagegen laeuft; das Schema
* beanstandet sie sonst erst beim Speichern.
*/
export function OptionenFelder({
optionen,
setzen,
lesend,
}: {
optionen: readonly string[];
setzen: (optionen: readonly string[]) => void;
lesend: boolean;
}) {
const voll = optionen.length >= GRENZEN.optionen;
return (
<div className="flex flex-col gap-2">
<span className="text-sm text-nebentext">Optionen</span>
{optionen.length === 0 ? (
<p className="text-xs text-faellig">
Ohne Option bleibt die Auswahl leer. Bitte mindestens eine angeben.
</p>
) : null}
<ul className="flex flex-col gap-2">
{optionen.map((option, index) => (
// Der Index als Schluessel ist hier richtig: die Optionen sind reine
// Zeichenketten ohne eigene Kennung, und geloescht wird immer die Zeile,
// auf die geklickt wurde.
<li key={index} className="flex items-center gap-2">
<input
value={option}
onChange={(ereignis) =>
setzen(optionen.map((alt, stelle) => (stelle === index ? ereignis.target.value : alt)))
}
readOnly={lesend}
maxLength={GRENZEN.text}
aria-label={`Option ${index + 1}`}
className={FELD}
/>
{lesend ? null : (
<LoeschKnopf
beschriftung={`Option ${index + 1} löschen`}
aufLoeschen={() => setzen(optionen.filter((_, stelle) => stelle !== index))}
/>
)}
</li>
))}
</ul>
{lesend ? null : (
<TextKnopf
beschriftung={voll ? `Höchstens ${GRENZEN.optionen} Optionen` : "Option hinzufügen"}
aus={voll}
aufKlick={() => setzen([...optionen, ""])}
/>
)}
</div>
);
}
/** Kleine Pille fuer Status, Typ und Kennzeichen. */
export function Kennzeichen({
children,
ton = "still",
}: {
children: React.ReactNode;
ton?: "still" | "akzent" | "faellig";
}) {
const toene = {
still: "bg-flaeche-hoch text-nebentext",
akzent: "bg-akzent-flaeche text-akzent",
faellig: "border border-linie text-faellig",
} as const;
return <span className={`shrink-0 rounded-pille px-2.5 py-1 text-xs ${toene[ton]}`}>{children}</span>;
}
/**
* Eine Karte, die sich zuklappen laesst (#010).
*
* Abschnitte, Schritte und Variablen sehen gleich aus: eine Kopfzeile, die immer
* sichtbar bleibt — Anfasser, Zusammenfassung, Knoepfe —, und darunter die
* Felder. Ohne das Zuklappen war eine einzige Beispielvorlage rund 17 000 px
* hoch; mit den sieben Vorlagen aus #017 waere der Editor nicht mehr bedienbar.
*
* Der Inhalt wird **ausgeblendet, nicht ausgehaengt**: die Felder bleiben im
* Baum und damit im React-Zustand. Wuerde man sie entfernen, verloere das
* Zuklappen alles, was seit dem letzten Speichern getippt wurde.
*
* Kein `<details>`: dessen `<summary>` schaltet bei jedem Klick um — auch beim
* Klick auf den Anfasser oder den Loeschknopf, die in der Kopfzeile stehen
* muessen, weil man auch zugeklappt sortieren und loeschen koennen soll.
*/
export function KlappKarte({
offen,
umschalten,
anfasser,
knoepfe,
kopf,
ueberschrift,
flaeche = "flaeche-hoch",
children,
}: {
offen: boolean;
umschalten: () => void;
anfasser: React.ReactNode;
knoepfe?: React.ReactNode;
/** Was zugeklappt zu sehen ist. Traegt selbst `truncate`, wo es lang werden kann. */
kopf: React.ReactNode;
/** Abschnitte sind Ueberschriften; Schritte und Variablen nicht. */
ueberschrift?: boolean;
flaeche?: "flaeche" | "flaeche-hoch";
children: React.ReactNode;
}) {
const inhaltKennung = useId();
const schalter = (
<button
type="button"
onClick={umschalten}
aria-expanded={offen}
aria-controls={inhaltKennung}
className="flex min-h-11 w-full min-w-40 flex-1 items-center gap-2 text-left text-sm md:min-h-10"
>
<ChevronDown
className={`size-4 shrink-0 text-nebentext ${offen ? "" : "-rotate-90"}`}
strokeWidth={2}
aria-hidden="true"
/>
{kopf}
</button>
);
return (
<div
className={`rounded-karte border border-linie ${
flaeche === "flaeche" ? "bg-flaeche" : "bg-flaeche-hoch"
}`}
>
{/*
Eine Zeile, die umbricht: am Schreibtisch stehen Zusammenfassung und
Knoepfe nebeneinander, bei 390 px rutschen die Knoepfe darunter, sobald
der Text seine 10 rem nicht mehr bekommt.
*/}
<div className="flex flex-wrap items-center gap-2 p-3">
{anfasser}
{ueberschrift ? <h3 className="flex min-w-40 flex-1">{schalter}</h3> : schalter}
{knoepfe ? <div className="ml-auto flex items-center gap-2">{knoepfe}</div> : null}
</div>
<div id={inhaltKennung} className={offen ? "flex flex-col gap-3 px-3 pb-3" : "hidden"}>
{children}
</div>
</div>
);
}
+259
View File
@@ -0,0 +1,259 @@
"use client";
import { useState } from "react";
import {
Auswahlfeld,
Flaechenfeld,
Kennzeichen,
KlappKarte,
Kontrollkasten,
LoeschKnopf,
Textfeld,
OptionenFelder,
Zahlfeld,
} from "@/components/vorlagen/editor-felder";
import { VerschiebeKnoepfe } from "@/components/vorlagen/editor-sortieren";
import {
SCHRITT_TYP_TEXT,
STANDARD_SHELL,
konfigFuerTyp,
type EditorSchritt,
type SchrittTyp,
} from "@/lib/editor-aufbau";
import { SCHRITT_TYPEN, SHELLS, type SchrittKonfig } from "@/lib/vorlagen-format";
/**
* Ein Schritt im Editor (#010).
*
* Bewusst keine Tabelle und keine festen Breiten: bei 390 px stehen Titel,
* Typ und die typabhaengigen Felder untereinander, ab `sm` nebeneinander.
*/
/**
* Einen Schluessel in der `konfig` setzen oder entfernen.
*
* Ein leeres Feld loescht den Schluessel, statt `""` zu speichern: `min` ohne
* Wert heisst „keine Grenze“, und eine leere Einheit ist keine Einheit.
*/
function konfigMit(konfig: SchrittKonfig, schluessel: string, wert: unknown): SchrittKonfig {
const neu: Record<string, unknown> = { ...konfig };
if (wert === undefined || wert === "") delete neu[schluessel];
else neu[schluessel] = wert;
return neu as SchrittKonfig;
}
function KonfigFelder({
typ,
konfig,
setzen,
lesend,
}: {
typ: SchrittTyp;
konfig: SchrittKonfig;
setzen: (konfig: SchrittKonfig) => void;
lesend: boolean;
}) {
switch (typ) {
case "TEXT":
return (
<Kontrollkasten
beschriftung="Mehrzeiliges Feld"
wert={konfig.mehrzeilig === true}
setzen={(wert) => setzen(konfigMit(konfig, "mehrzeilig", wert))}
lesend={lesend}
/>
);
case "ZAHL":
return (
<div className="grid gap-3 sm:grid-cols-3">
<Zahlfeld
beschriftung="Mindestens"
wert={konfig.min}
setzen={(wert) => setzen(konfigMit(konfig, "min", wert))}
lesend={lesend}
/>
<Zahlfeld
beschriftung="Höchstens"
wert={konfig.max}
setzen={(wert) => setzen(konfigMit(konfig, "max", wert))}
lesend={lesend}
/>
<Textfeld
beschriftung="Einheit"
wert={konfig.einheit ?? ""}
setzen={(wert) => setzen(konfigMit(konfig, "einheit", wert))}
lesend={lesend}
maxLaenge={40}
vorschlag="GB"
hinweis="Grenzen sind Hinweise, kein Verbot."
/>
</div>
);
case "AUSWAHL":
return (
<OptionenFelder
optionen={konfig.optionen ?? []}
setzen={(optionen) => setzen(konfigMit(konfig, "optionen", [...optionen]))}
lesend={lesend}
/>
);
case "BEFEHL":
return (
<div className="flex flex-col gap-3">
<div className="sm:max-w-48">
<Auswahlfeld
beschriftung="Shell"
wert={konfig.shell ?? STANDARD_SHELL}
werte={SHELLS}
setzen={(wert) => setzen(konfigMit(konfig, "shell", wert))}
lesend={lesend}
/>
</div>
<Flaechenfeld
beschriftung="Befehl"
wert={konfig.befehl ?? ""}
setzen={(wert) => setzen(konfigMit(konfig, "befehl", wert))}
lesend={lesend}
zeilen={4}
mono
vorschlag={'New-ADUser -Name "{{vorname}} {{nachname}}"'}
hinweis="Platzhalter werden erst beim Abarbeiten ersetzt."
/>
</div>
);
default:
// HAKEN und DATUM haben keine konfig. Dass dort nichts steht, sieht man;
// ein Satz darueber waere Rauschen an jedem zweiten Schritt.
return null;
}
}
export function SchrittKarte({
schritt,
index,
anzahl,
lesend,
neu,
anfasser,
aendern,
loeschen,
aufSortiert,
}: {
schritt: EditorSchritt;
index: number;
anzahl: number;
lesend: boolean;
/** Gerade angelegt: dann aufgeklappt, denn man will ihn ausfuellen. */
neu: boolean;
anfasser: React.ReactNode;
aendern: (teil: Partial<EditorSchritt>) => void;
loeschen: () => void;
aufSortiert: (von: number, nach: number) => void;
}) {
const [offen, setzeOffen] = useState(neu);
/**
* Beim Typwechsel wird die `konfig` aufgeraeumt — sonst wanderte der Befehl
* eines ehemaligen `BEFEHL`-Schritts unsichtbar mit. `BEFEHL` bekommt dabei
* gleich eine Shell, damit gespeichert ist, was das Formular anzeigt.
*/
function typSetzen(typ: SchrittTyp) {
const gesaeubert = konfigFuerTyp(schritt.konfig, typ);
aendern({
typ,
konfig:
typ === "BEFEHL" && gesaeubert.shell === undefined
? konfigMit(gesaeubert, "shell", STANDARD_SHELL)
: gesaeubert,
});
}
const titel = schritt.titel.trim();
return (
<KlappKarte
offen={offen}
umschalten={() => setzeOffen((vorher) => !vorher)}
anfasser={anfasser}
kopf={
<>
<span className="mono shrink-0 text-xs text-nebentext">{index + 1}</span>
{schritt.pflicht ? (
<span className="shrink-0 text-fehler" title="Pflichtschritt">
*
</span>
) : null}
{/* Ohne Titel bliebe die Zeile leer — und der Schritt unauffindbar. */}
<span className={`truncate ${titel === "" ? "text-nebentext" : ""}`}>
{titel === "" ? "ohne Titel" : titel}
</span>
<Kennzeichen>{SCHRITT_TYP_TEXT[schritt.typ]}</Kennzeichen>
</>
}
knoepfe={
lesend ? null : (
<>
<VerschiebeKnoepfe art="Schritt" index={index} anzahl={anzahl} aufSortiert={aufSortiert} />
<LoeschKnopf beschriftung="Schritt löschen" aufLoeschen={loeschen} />
</>
)
}
>
<Textfeld
beschriftung="Titel"
wert={schritt.titel}
setzen={(neuerTitel) => aendern({ titel: neuerTitel })}
lesend={lesend}
vorschlag="Was ist zu tun?"
/>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="sm:max-w-48">
<Auswahlfeld
beschriftung="Typ"
wert={schritt.typ}
werte={SCHRITT_TYPEN}
setzen={typSetzen}
lesend={lesend}
beschriftungen={SCHRITT_TYP_TEXT}
/>
</div>
<div className="sm:mt-6">
<Kontrollkasten
beschriftung="Pflichtschritt"
wert={schritt.pflicht}
setzen={(pflicht) => aendern({ pflicht })}
lesend={lesend}
/>
</div>
</div>
<KonfigFelder
typ={schritt.typ}
konfig={schritt.konfig}
setzen={(konfig) => aendern({ konfig })}
lesend={lesend}
/>
<Flaechenfeld
beschriftung="Hilfetext"
wert={schritt.hilfetext}
setzen={(hilfetext) => aendern({ hilfetext })}
lesend={lesend}
hinweis="Markdown: **fett**, Listen, Links, `Code`. Kein HTML."
/>
<Textfeld
beschriftung="Doku-Link"
wert={schritt.dokuUrl}
setzen={(dokuUrl) => aendern({ dokuUrl })}
lesend={lesend}
vorschlag="https://intern.example/anleitung"
hinweis="Nur http- oder https-Adressen."
/>
</KlappKarte>
);
}
@@ -0,0 +1,210 @@
"use client";
import { useId } from "react";
import {
DndContext,
KeyboardSensor,
MouseSensor,
TouchSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { ChevronDown, ChevronUp, GripVertical } from "lucide-react";
import { SymbolKnopf } from "@/components/vorlagen/editor-felder";
/**
* Reihenfolge aendern — mit dem Finger, der Maus, der Tastatur oder zwei Knoepfen (#010).
*
* Drei Sensoren, weil ein Anfasser allein nicht reicht:
*
* - `TouchSensor` mit Verzoegerung: ohne sie waere jede Wischbewegung, die auf
* dem Anfasser beginnt, ein Verschieben statt eines Bildlaufs. 200 ms Halten
* sind genug, um das eine vom anderen zu unterscheiden.
* - `MouseSensor` mit 4 px Schwelle, damit ein Klick kein Verschieben auslöst.
* Bewusst nicht der `PointerSensor`: der bediente Maus und Finger zugleich und
* liesse sich dann nicht mehr fuer den Finger allein einstellen.
* - `KeyboardSensor` fuer Leertaste und Pfeiltasten.
*
* Dazu die Knoepfe „nach oben“ und „nach unten“ an jeder Zeile: ein Touch-Drag
* auf einem 390-px-Gerät gelingt nicht jedem zuverlässig, und die Reihenfolge
* ist fachlich wichtig genug, um einen zweiten, langsamen Weg zu haben.
*/
/** Kein Import aus `@dnd-kit/utilities` — das Paket ist nur eine Abhaengigkeit der Abhaengigkeit. */
function schiebeStil(
transform: { x: number; y: number } | null,
transition: string | undefined,
): React.CSSProperties | undefined {
if (!transform) return transition ? { transition } : undefined;
return { transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`, transition };
}
export function SortierBereich({
kennungen,
art,
lesend,
aufSortiert,
children,
}: {
kennungen: readonly string[];
/** „Abschnitt“, „Schritt“, „Variable“ — fuer die Ansagen der Hilfsmittel. */
art: string;
lesend: boolean;
aufSortiert: (von: number, nach: number) => void;
children: React.ReactNode;
}) {
/**
* Eine feste Kennung fuer den Sortierbereich.
*
* Ohne sie vergibt `@dnd-kit` die `aria-describedby`-Kennung aus einem
* modulweiten Zaehler. Der steht auf dem Server anders als im Browser — und
* genau das meldet React beim Hydrieren als Unterschied. `useId` zaehlt in
* beiden Durchlaeufen gleich.
*/
const bereichKennung = useId();
const sensoren = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 4 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
// Im Lesemodus gar kein DndContext: was sich nicht aendern darf, soll auch
// keine Ansage machen, dass es sich verschieben liesse.
if (lesend) return children;
function aufEnde(ereignis: DragEndEvent) {
const { active, over } = ereignis;
if (!over || active.id === over.id) return;
const von = kennungen.indexOf(String(active.id));
const nach = kennungen.indexOf(String(over.id));
if (von < 0 || nach < 0) return;
aufSortiert(von, nach);
}
return (
<DndContext
id={bereichKennung}
sensors={sensoren}
collisionDetection={closestCenter}
onDragEnd={aufEnde}
accessibility={{
screenReaderInstructions: {
draggable: `${art} mit der Leertaste aufnehmen, mit den Pfeiltasten verschieben, mit der Leertaste ablegen, mit Escape abbrechen.`,
},
announcements: {
onDragStart: () => `${art} aufgenommen.`,
onDragOver: () => undefined,
onDragEnd: ({ over }) => (over ? `${art} verschoben.` : `${art} unverändert abgelegt.`),
onDragCancel: () => "Verschieben abgebrochen.",
},
}}
>
<SortableContext items={[...kennungen]} strategy={verticalListSortingStrategy}>
{children}
</SortableContext>
</DndContext>
);
}
/**
* Eine Zeile, die sich verschieben laesst.
*
* Der Anfasser kommt als Bauteil zurueck, damit die Zeile selbst entscheidet, wo
* er sitzt. Im Lesemodus eine andere Komponente statt eines abgeschalteten
* `useSortable`: so haengt kein Sortier-Zustand an etwas, das unveraenderlich ist.
*/
export function SortierZeile({
kennung,
lesend,
className,
children,
}: {
kennung: string;
lesend: boolean;
className?: string;
children: (anfasser: React.ReactNode) => React.ReactNode;
}) {
if (lesend) return <li className={className}>{children(null)}</li>;
return (
<BeweglicheZeile kennung={kennung} className={className}>
{children}
</BeweglicheZeile>
);
}
function BeweglicheZeile({
kennung,
className,
children,
}: {
kennung: string;
className?: string;
children: (anfasser: React.ReactNode) => React.ReactNode;
}) {
const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging } =
useSortable({ id: kennung });
const anfasser = (
<button
type="button"
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
title="Verschieben"
aria-label="Verschieben"
// touch-none, damit die Geste auf dem Anfasser nicht zum Bildlauf wird.
className="flex size-11 shrink-0 cursor-grab touch-none items-center justify-center rounded-pille text-nebentext hover:bg-flaeche-hoch hover:text-text md:size-10"
>
<GripVertical className="size-5" strokeWidth={1.75} aria-hidden="true" />
</button>
);
return (
<li
ref={setNodeRef}
style={schiebeStil(transform, transition)}
className={`${className ?? ""} ${isDragging ? "relative z-10 border-akzent opacity-90" : ""}`}
>
{children(anfasser)}
</li>
);
}
/** Der zweite Weg: eine Position nach oben oder nach unten, ohne Ziehen. */
export function VerschiebeKnoepfe({
art,
index,
anzahl,
aufSortiert,
}: {
art: string;
index: number;
anzahl: number;
aufSortiert: (von: number, nach: number) => void;
}) {
return (
<>
<SymbolKnopf
symbol={ChevronUp}
beschriftung={`${art} nach oben`}
aus={index === 0}
aufKlick={() => aufSortiert(index, index - 1)}
/>
<SymbolKnopf
symbol={ChevronDown}
beschriftung={`${art} nach unten`}
aus={index === anzahl - 1}
aufKlick={() => aufSortiert(index, index + 1)}
/>
</>
);
}
@@ -0,0 +1,252 @@
"use client";
import { useState } from "react";
import { TriangleAlert } from "lucide-react";
import {
Auswahlfeld,
Kennzeichen,
KlappKarte,
Kontrollkasten,
LoeschKnopf,
OptionenFelder,
TextKnopf,
Textfeld,
} from "@/components/vorlagen/editor-felder";
import { SortierBereich, SortierZeile, VerschiebeKnoepfe } from "@/components/vorlagen/editor-sortieren";
import {
SCHLUESSEL_REGEL,
VARIABLEN_TYP_TEXT,
doppelteSchluessel,
schluesselFehler,
type EditorVariable,
} from "@/lib/editor-aufbau";
import { unbenutzteVariablen } from "@/lib/platzhalter";
import { GRENZEN, VARIABLEN_TYPEN } from "@/lib/vorlagen-format";
/**
* Die Variablen einer Vorlage (#010).
*
* Sie werden beim Start eines Laufs abgefragt und fuellen die Platzhalter.
* Die Schluesselregel steht einmal ueber der Liste statt unter jedem Feld — und
* am Feld nur dann, wenn der Schluessel gerade dagegen verstoesst. Ein Hinweis,
* der zwanzigmal untereinander steht, wird nicht mehr gelesen.
*/
function VariablenKarte({
variable,
index,
anzahl,
lesend,
neu,
fehler,
unbenutzt,
anfasser,
aendern,
loeschen,
umsortieren,
}: {
variable: EditorVariable;
index: number;
anzahl: number;
lesend: boolean;
neu: boolean;
fehler: string | undefined;
unbenutzt: boolean;
anfasser: React.ReactNode;
aendern: (teil: Partial<EditorVariable>) => void;
loeschen: () => void;
umsortieren: (von: number, nach: number) => void;
}) {
const [offen, setzeOffen] = useState(neu);
const schluessel = variable.schluessel.trim();
return (
<KlappKarte
offen={offen}
umschalten={() => setzeOffen((vorher) => !vorher)}
anfasser={anfasser}
kopf={
<>
<span className="mono shrink-0 text-xs text-nebentext">{index + 1}</span>
{/* Zugeklappt muss ein fehlerhafter Schluessel auffallen, sonst sucht ihn niemand. */}
{fehler ? (
<TriangleAlert
className="size-4 shrink-0 text-fehler"
strokeWidth={1.75}
aria-label="Fehler im Schlüssel"
/>
) : null}
<code className={`mono shrink-0 text-xs ${schluessel === "" ? "text-nebentext" : ""}`}>
{schluessel === "" ? "ohne Schlüssel" : `{{${schluessel}}}`}
</code>
{/*
* Bei 390 px blieben von der Beschriftung zwei Zeichen uebrig („Be…“).
* Der Schluessel ist das, wonach man sucht; die Beschriftung steht
* eine Zeile tiefer, sobald die Karte offen ist.
*/}
<span className="hidden truncate text-nebentext sm:inline">{variable.beschriftung}</span>
<Kennzeichen>{VARIABLEN_TYP_TEXT[variable.typ]}</Kennzeichen>
</>
}
knoepfe={
lesend ? null : (
<>
<VerschiebeKnoepfe art="Variable" index={index} anzahl={anzahl} aufSortiert={umsortieren} />
<LoeschKnopf beschriftung="Variable löschen" aufLoeschen={loeschen} />
</>
)
}
>
<div className="grid gap-3 sm:grid-cols-2">
<Textfeld
beschriftung="Schlüssel"
wert={variable.schluessel}
setzen={(wert) => aendern({ schluessel: wert })}
lesend={lesend}
mono
maxLaenge={40}
vorschlag="vorname"
{...(fehler ? { fehler } : {})}
/>
<Textfeld
beschriftung="Beschriftung"
wert={variable.beschriftung}
setzen={(wert) => aendern({ beschriftung: wert })}
lesend={lesend}
vorschlag="Vorname"
/>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="sm:max-w-48">
<Auswahlfeld
beschriftung="Typ"
wert={variable.typ}
werte={VARIABLEN_TYPEN}
setzen={(typ) => aendern({ typ })}
lesend={lesend}
beschriftungen={VARIABLEN_TYP_TEXT}
/>
</div>
<div className="sm:mt-6">
<Kontrollkasten
beschriftung="Pflichtangabe beim Start"
wert={variable.pflicht}
setzen={(pflicht) => aendern({ pflicht })}
lesend={lesend}
/>
</div>
</div>
{variable.typ === "AUSWAHL" ? (
<OptionenFelder
optionen={variable.optionen}
setzen={(optionen) => aendern({ optionen })}
lesend={lesend}
/>
) : null}
{schluessel !== "" && unbenutzt ? (
<p className="text-xs text-nebentext">
Wird nirgends verwendet. Das ist erlaubt — sie wird trotzdem beim Start abgefragt.
</p>
) : null}
</KlappKarte>
);
}
export function VariablenBereich({
variablen,
verwendet,
lesend,
zuletztAngelegt,
aendern,
hinzufuegen,
loeschen,
umsortieren,
}: {
variablen: readonly EditorVariable[];
/** Die im Aufbau tatsaechlich vorkommenden Platzhalter. */
verwendet: readonly string[];
lesend: boolean;
/** Kennung der zuletzt angelegten Zeile — die wird aufgeklappt gezeigt. */
zuletztAngelegt: string | null;
aendern: (kennung: string, teil: Partial<EditorVariable>) => void;
hinzufuegen: () => void;
loeschen: (kennung: string) => void;
umsortieren: (von: number, nach: number) => void;
}) {
const doppelt = new Set(doppelteSchluessel(variablen));
const unbenutzt = new Set(
unbenutzteVariablen(
verwendet,
variablen.map((variable) => variable.schluessel.trim()).filter((schluessel) => schluessel !== ""),
),
);
const voll = variablen.length >= GRENZEN.variablen;
return (
<section className="flex flex-col gap-3 rounded-karte border border-linie bg-flaeche p-4">
<div>
<h2 className="text-lg font-semibold">Variablen</h2>
<p className="mt-1 max-w-prose text-sm text-nebentext">
Werden beim Start eines Laufs abgefragt und füllen die Platzhalter{" "}
<code className="mono">{"{{schluessel}}"}</code> in Titeln, Hilfetexten und Befehlen.{" "}
{SCHLUESSEL_REGEL}
</p>
</div>
{variablen.length === 0 ? (
<p className="text-sm text-nebentext">
Keine Variablen. Eine Vorlage ohne Variablen ist in Ordnung — die eingebauten Platzhalter stehen
trotzdem zur Verfügung.
</p>
) : (
<SortierBereich
kennungen={variablen.map((variable) => variable.kennung)}
art="Variable"
lesend={lesend}
aufSortiert={umsortieren}
>
<ul className="flex flex-col gap-3">
{variablen.map((variable, index) => {
const schluessel = variable.schluessel.trim();
const fehler = doppelt.has(schluessel)
? `Den Schlüssel „${schluessel}“ gibt es zweimal.`
: schluessel === ""
? undefined
: schluesselFehler(variable.schluessel);
return (
<SortierZeile key={variable.kennung} kennung={variable.kennung} lesend={lesend}>
{(anfasser) => (
<VariablenKarte
variable={variable}
index={index}
anzahl={variablen.length}
lesend={lesend}
neu={variable.kennung === zuletztAngelegt}
fehler={fehler}
unbenutzt={unbenutzt.has(schluessel)}
anfasser={anfasser}
aendern={(teil) => aendern(variable.kennung, teil)}
loeschen={() => loeschen(variable.kennung)}
umsortieren={umsortieren}
/>
)}
</SortierZeile>
);
})}
</ul>
</SortierBereich>
)}
{lesend ? null : (
<TextKnopf
beschriftung={voll ? `Höchstens ${GRENZEN.variablen} Variablen` : "Variable hinzufügen"}
aus={voll}
aufKlick={hinzufuegen}
/>
)}
</section>
);
}
+507
View File
@@ -0,0 +1,507 @@
"use client";
import { useActionState, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ArrowLeft, Lock, Save, Send, TriangleAlert } from "lucide-react";
import { AbschnittKarte } from "@/components/vorlagen/editor-abschnitt";
import { Kennzeichen, TextKnopf } from "@/components/vorlagen/editor-felder";
import { SortierBereich, SortierZeile } from "@/components/vorlagen/editor-sortieren";
import { VariablenBereich } from "@/components/vorlagen/editor-variablen";
import { PlatzhalterHilfe } from "@/components/vorlagen/platzhalter-hilfe";
import { Vorschau } from "@/components/vorlagen/vorschau";
import {
alsEditorAufbau,
alsStruktur,
neuerAbschnitt,
neuerSchritt,
neueVariable,
umsortieren,
verwendetePlatzhalter,
type EditorAbschnitt,
type EditorAufbau,
type EditorSchritt,
type EditorVariable,
} from "@/lib/editor-aufbau";
import { unbekanntePlatzhalter } from "@/lib/platzhalter";
import { GRENZEN, type Struktur } from "@/lib/vorlagen-format";
import { entwurfSpeichernAktion, veroeffentlichenAktion } from "@/server/actions/vorlagen";
/**
* Der Vorlagen-Editor (#010).
*
* Haelt den ganzen Aufbau im Zustand und schickt ihn beim Speichern als ein
* Stueck JSON an `entwurfSpeichernAktion`. Kein Autosave: eine halb umgebaute
* Vorlage soll nicht nebenbei zur Wahrheit werden, und ein Speichern, das
* niemand ausgeloest hat, ueberschreibt im Zweifel die Arbeit eines anderen.
*
* Eine veroeffentlichte Version wird mit denselben Bauteilen gezeigt, nur mit
* `lesend`. Zwei Codepfade waeren zwei Gelegenheiten, dass die Ansicht etwas
* anderes zeigt als der Editor.
*/
export type EditorVersion = {
readonly id: string;
readonly vorlageId: string;
readonly vorlageName: string;
readonly version: number;
readonly veroeffentlicht: boolean;
readonly struktur: Struktur;
/** Stand beim Laden als ISO-Zeichenkette; geht beim Speichern zurueck. */
readonly stand: string;
readonly aenderungsnotiz: string | null;
};
/**
* Was der Editor ueber das Speichern wissen muss.
*
* Steckt in einem Zustand, weil beides ueber mehrere Versuche hinweg gelten
* muss: der `stand` des letzten erfolgreichen Speicherns und der Aufbau, der
* dabei geschrieben wurde. Nur so kann ein fehlgeschlagener zweiter Versuch
* nicht den Stand des ersten wieder vergessen.
*/
type SpeicherZustand = {
readonly fehler?: string;
readonly erfolg?: string;
readonly stand: string;
readonly gespeichertesJson: string;
};
export function Editor({
version,
lesend,
darfBearbeiten,
beispielDatum,
}: {
version: EditorVersion;
lesend: boolean;
darfBearbeiten: boolean;
/** Heutiges Datum, auf dem Server formatiert — siehe `beispielwerte`. */
beispielDatum: string;
}) {
const router = useRouter();
const [aufbau, setzeAufbau] = useState<EditorAufbau>(() => alsEditorAufbau(version.struktur));
const aktuellesJson = useMemo(() => JSON.stringify(alsStruktur(aufbau)), [aufbau]);
/**
* Der geladene Aufbau, einmal durch den Editor gedreht.
*
* Der Vergleichspunkt fuer „nicht gespeichert“ muss dieselbe Reise gemacht
* haben wie der Zustand: ein Titel, der seine mitgespeicherte Nummer verliert,
* ist keine Aenderung des Benutzers. Die Angleichung wandert mit dem naechsten
* Speichern in die Datenbank.
*/
const anfangsJson = useMemo(
() => JSON.stringify(alsStruktur(alsEditorAufbau(version.struktur))),
[version.struktur],
);
/**
* Das Speichern haengt sich um die Server Action und merkt sich zwei Dinge,
* die sie nicht zurueckgibt: den letzten guten `stand` und den Aufbau, der
* damit geschrieben wurde. Der Aufbau kommt aus dem abgeschickten Formular —
* damit ist es genau der, den der Server gesehen hat, und nicht der, an dem
* jemand waehrend der Anfrage weitergeschrieben hat.
*/
async function speichernMitStand(vorher: SpeicherZustand, formular: FormData): Promise<SpeicherZustand> {
const ergebnis = await entwurfSpeichernAktion({}, formular);
const gesendet = formular.get("struktur");
return {
...ergebnis,
stand: ergebnis.stand ?? vorher.stand,
gespeichertesJson: ergebnis.stand && typeof gesendet === "string" ? gesendet : vorher.gespeichertesJson,
};
}
const [speicherErgebnis, speichern, speichertGerade] = useActionState(speichernMitStand, {
stand: version.stand,
gespeichertesJson: anfangsJson,
});
const [freigabeErgebnis, veroeffentlichen, veroeffentlichtGerade] = useActionState(
veroeffentlichenAktion,
{},
);
const nichtGespeichert = aktuellesJson !== speicherErgebnis.gespeichertesJson;
/**
* Die zuletzt angelegte Zeile wird aufgeklappt gezeigt — alles andere bleibt
* zu. Nur die neueste: aeltere Karten haengen laengst an ihrem eigenen
* Zustand, den sie beim Anlegen bekommen haben, und behalten ihn.
*/
const [zuletztAngelegt, setzeZuletztAngelegt] = useState<string | null>(null);
// Nach dem Veroeffentlichen ist diese Version unveraenderlich. Die Seite muss
// neu geladen werden, damit der Editor in den Lesemodus geht; ein
// revalidatePath auf /vorlagen erreicht diese Adresse nicht.
useEffect(() => {
if (freigabeErgebnis.erfolg) router.refresh();
}, [freigabeErgebnis, router]);
/**
* Der Browser fragt vor dem Verlassen nach, solange etwas nicht gespeichert
* ist. Greift nur bei echtem Verlassen der Seite, nicht bei einem Klick in der
* Navigation — deshalb steht das Kennzeichen zusaetzlich sichtbar oben.
*/
useEffect(() => {
if (lesend || !nichtGespeichert) return undefined;
const warnen = (ereignis: BeforeUnloadEvent) => ereignis.preventDefault();
window.addEventListener("beforeunload", warnen);
return () => window.removeEventListener("beforeunload", warnen);
}, [lesend, nichtGespeichert]);
const verwendet = verwendetePlatzhalter(aufbau);
const unbekannt = unbekanntePlatzhalter(
verwendet,
aufbau.variablen.map((variable) => variable.schluessel.trim()),
);
const konflikt = speicherErgebnis.fehler?.includes("zwischenzeitlich") === true;
// Abschnitte
function abschnitteSetzen(neue: readonly EditorAbschnitt[]) {
setzeAufbau((alt) => ({ ...alt, abschnitte: neue }));
}
function abschnittAendern(kennung: string, teil: Partial<EditorAbschnitt>) {
setzeAufbau((alt) => ({
...alt,
abschnitte: alt.abschnitte.map((abschnitt) =>
abschnitt.kennung === kennung ? { ...abschnitt, ...teil } : abschnitt,
),
}));
}
function schritteSetzen(
abschnittKennung: string,
aendere: (schritte: readonly EditorSchritt[]) => readonly EditorSchritt[],
) {
setzeAufbau((alt) => ({
...alt,
abschnitte: alt.abschnitte.map((abschnitt) =>
abschnitt.kennung === abschnittKennung
? { ...abschnitt, schritte: aendere(abschnitt.schritte) }
: abschnitt,
),
}));
}
// Variablen
function variablenSetzen(neue: readonly EditorVariable[]) {
setzeAufbau((alt) => ({ ...alt, variablen: neue }));
}
const abschnitteVoll = aufbau.abschnitte.length >= GRENZEN.abschnitte;
return (
<div className="flex flex-col gap-6">
<header className="flex flex-col gap-3">
<Link
href={`/vorlagen/${version.vorlageId}`}
className="flex min-h-11 items-center gap-2 self-start text-sm text-nebentext hover:text-text md:min-h-10"
>
<ArrowLeft className="size-[18px]" strokeWidth={1.75} aria-hidden="true" />
Zur Vorlage
</Link>
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-bold">{version.vorlageName}</h1>
<Kennzeichen>Version {version.version}</Kennzeichen>
<Kennzeichen ton={version.veroeffentlicht ? "akzent" : "still"}>
{version.veroeffentlicht ? "veröffentlicht" : "Entwurf"}
</Kennzeichen>
{lesend ? null : nichtGespeichert ? (
<Kennzeichen ton="faellig">Nicht gespeichert</Kennzeichen>
) : (
<Kennzeichen>gespeichert</Kennzeichen>
)}
</div>
</header>
{lesend ? (
<div className="flex items-start gap-3 rounded-karte border border-linie bg-flaeche p-4">
<Lock
className="mt-0.5 size-[18px] shrink-0 text-nebentext"
strokeWidth={1.75}
aria-hidden="true"
/>
<p className="max-w-prose text-sm text-nebentext">
{version.veroeffentlicht ? (
<>
Diese Version ist veröffentlicht und wird nicht mehr verändert — Läufe hängen daran.{" "}
{darfBearbeiten ? (
<>
„Bearbeiten“ auf der{" "}
<Link
href={`/vorlagen/${version.vorlageId}`}
className="text-akzent underline underline-offset-2"
>
Vorlagenseite
</Link>{" "}
erzeugt daraus einen neuen Entwurf; je Vorlage gibt es höchstens einen.
</>
) : (
"Ein neuer Entwurf entsteht über „Bearbeiten“ auf der Vorlagenseite; dafür braucht es die Rolle Redakteur oder Administrator."
)}
</>
) : (
"Dieser Entwurf lässt sich ansehen, aber nicht ändern — dafür braucht es die Rolle Redakteur oder Administrator."
)}
</p>
</div>
) : null}
{unbekannt.length > 0 ? (
<div className="flex items-start gap-3 rounded-karte border border-linie bg-flaeche p-4">
<TriangleAlert
className="mt-0.5 size-[18px] shrink-0 text-faellig"
strokeWidth={1.75}
aria-hidden="true"
/>
<div className="min-w-0">
<p className="text-sm text-faellig">
{unbekannt.length === 1
? "Ein Platzhalter gehört zu keiner Variablen und zu keinem eingebauten Wert:"
: `${unbekannt.length} Platzhalter gehören zu keiner Variablen und zu keinem eingebauten Wert:`}
</p>
<ul className="mt-2 flex flex-wrap gap-2">
{unbekannt.map((schluessel) => (
<li key={schluessel}>
<code className="mono rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-faellig">
{`{{${schluessel}}}`}
</code>
</li>
))}
</ul>
<p className="mt-2 max-w-prose text-sm text-nebentext">
Im Lauf bleibt ein unbekannter Platzhalter als Text stehen. Speichern und Veröffentlichen sind
trotzdem möglich — vielleicht kommt die Variable ja noch.
</p>
</div>
</div>
) : null}
<VariablenBereich
variablen={aufbau.variablen}
verwendet={verwendet}
lesend={lesend}
zuletztAngelegt={zuletztAngelegt}
aendern={(kennung, teil) =>
variablenSetzen(
aufbau.variablen.map((variable) =>
variable.kennung === kennung ? { ...variable, ...teil } : variable,
),
)
}
hinzufuegen={() => {
const variable = neueVariable();
setzeZuletztAngelegt(variable.kennung);
variablenSetzen([...aufbau.variablen, variable]);
}}
loeschen={(kennung) =>
variablenSetzen(aufbau.variablen.filter((variable) => variable.kennung !== kennung))
}
umsortieren={(von, nach) => variablenSetzen(umsortieren(aufbau.variablen, von, nach))}
/>
<section className="flex flex-col gap-3">
<h2 className="text-lg font-semibold">Abschnitte</h2>
{aufbau.abschnitte.length === 0 ? (
<p className="text-sm text-nebentext">
Noch kein Abschnitt. Ein Abschnitt fasst die Schritte zusammen, die zusammengehören.
</p>
) : (
<SortierBereich
kennungen={aufbau.abschnitte.map((abschnitt) => abschnitt.kennung)}
art="Abschnitt"
lesend={lesend}
aufSortiert={(von, nach) => abschnitteSetzen(umsortieren(aufbau.abschnitte, von, nach))}
>
<ul className="flex flex-col gap-4">
{aufbau.abschnitte.map((abschnitt, index) => (
<SortierZeile key={abschnitt.kennung} kennung={abschnitt.kennung} lesend={lesend}>
{(anfasser) => (
<AbschnittKarte
abschnitt={abschnitt}
index={index}
anzahl={aufbau.abschnitte.length}
lesend={lesend}
zuletztAngelegt={zuletztAngelegt}
anfasser={anfasser}
aendern={(teil) => abschnittAendern(abschnitt.kennung, teil)}
loeschen={() =>
abschnitteSetzen(
aufbau.abschnitte.filter((eintrag) => eintrag.kennung !== abschnitt.kennung),
)
}
aufSortiert={(von, nach) => abschnitteSetzen(umsortieren(aufbau.abschnitte, von, nach))}
schrittAendern={(kennung, teil) =>
schritteSetzen(abschnitt.kennung, (schritte) =>
schritte.map((schritt) =>
schritt.kennung === kennung ? { ...schritt, ...teil } : schritt,
),
)
}
schrittHinzufuegen={() => {
const schritt = neuerSchritt();
setzeZuletztAngelegt(schritt.kennung);
schritteSetzen(abschnitt.kennung, (schritte) => [...schritte, schritt]);
}}
schrittLoeschen={(kennung) =>
schritteSetzen(abschnitt.kennung, (schritte) =>
schritte.filter((schritt) => schritt.kennung !== kennung),
)
}
schritteUmsortieren={(von, nach) =>
schritteSetzen(abschnitt.kennung, (schritte) => umsortieren(schritte, von, nach))
}
/>
)}
</SortierZeile>
))}
</ul>
</SortierBereich>
)}
{lesend ? null : (
<TextKnopf
beschriftung={
abschnitteVoll ? `Höchstens ${GRENZEN.abschnitte} Abschnitte` : "Abschnitt hinzufügen"
}
aus={abschnitteVoll}
aufKlick={() => {
const abschnitt = neuerAbschnitt();
setzeZuletztAngelegt(abschnitt.kennung);
abschnitteSetzen([...aufbau.abschnitte, abschnitt]);
}}
/>
)}
</section>
{/* Ohne Set stuende ein doppelt vergebener Schluessel zweimal in der Hilfe. */}
<PlatzhalterHilfe
variablen={[
...new Set(
aufbau.variablen
.map((variable) => variable.schluessel.trim())
.filter((schluessel) => schluessel !== ""),
),
]}
/>
<Vorschau aufbau={aufbau} beispielDatum={beispielDatum} />
{lesend ? null : (
<>
<section className="flex flex-col gap-3 rounded-karte border border-linie bg-flaeche p-4">
<h2 className="text-lg font-semibold">Veröffentlichen</h2>
<p className="max-w-prose text-sm text-nebentext">
Aus dem Entwurf wird Version {version.version}. Sie ist danach unveränderlich, und Läufe können
daran starten. Gespeichert wird veröffentlicht — nicht, was hier gerade auf dem Bildschirm
steht.
</p>
<form action={veroeffentlichen} className="flex flex-col gap-3">
<input type="hidden" name="versionId" value={version.id} />
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Änderungsnotiz (optional)</span>
<textarea
name="aenderungsnotiz"
rows={2}
defaultValue={version.aenderungsnotiz ?? ""}
maxLength={GRENZEN.text}
className="min-h-11 w-full rounded-karte border border-linie bg-grund px-3 py-2 text-sm text-text outline-none focus-visible:border-akzent"
/>
</label>
{nichtGespeichert ? (
<p className="flex items-start gap-2 text-sm text-faellig">
<TriangleAlert className="mt-0.5 size-4 shrink-0" strokeWidth={1.75} aria-hidden="true" />
Es gibt nicht gespeicherte Änderungen. Sie wären in der veröffentlichten Version nicht
enthalten — erst speichern.
</p>
) : null}
{freigabeErgebnis.fehler ? (
<p role="alert" className="text-sm text-fehler">
{freigabeErgebnis.fehler}
</p>
) : null}
{freigabeErgebnis.erfolg ? (
<p role="status" className="text-sm text-erledigt">
{freigabeErgebnis.erfolg}
</p>
) : null}
<button
type="submit"
disabled={veroeffentlichtGerade}
className="flex min-h-11 items-center gap-2 self-start rounded-pille border border-linie px-5 text-sm font-semibold text-akzent hover:bg-akzent-flaeche disabled:pointer-events-none disabled:opacity-40 md:min-h-10"
>
<Send className="size-[18px]" strokeWidth={1.75} aria-hidden="true" />
{veroeffentlichtGerade ? "Wird veröffentlicht …" : "Veröffentlichen"}
</button>
</form>
</section>
<form
action={speichern}
className="sticky bottom-0 z-10 -mx-4 flex flex-col gap-2 border-t border-linie bg-flaeche px-4 py-3 md:-mx-8 md:px-8"
>
<input type="hidden" name="versionId" value={version.id} />
<input type="hidden" name="stand" value={speicherErgebnis.stand} />
<input type="hidden" name="struktur" value={aktuellesJson} />
<div className="flex flex-wrap items-center gap-3">
<button
type="submit"
disabled={speichertGerade}
className="flex min-h-11 items-center gap-2 rounded-pille bg-akzent px-5 text-sm font-semibold text-auf-akzent hover:bg-akzent/80 disabled:pointer-events-none disabled:opacity-40 md:min-h-10"
>
<Save className="size-[18px]" strokeWidth={2} aria-hidden="true" />
{speichertGerade ? "Wird gespeichert …" : "Speichern"}
</button>
<span className={`text-sm ${nichtGespeichert ? "text-faellig" : "text-nebentext"}`}>
{nichtGespeichert ? "Nicht gespeicherte Änderungen" : "Alles gespeichert"}
</span>
</div>
{speicherErgebnis.fehler ? (
<p role="alert" className="text-sm text-fehler">
{speicherErgebnis.fehler}
</p>
) : null}
{speicherErgebnis.erfolg && !nichtGespeichert ? (
<p role="status" className="text-sm text-erledigt">
{speicherErgebnis.erfolg}
</p>
) : null}
{/*
* Der Rettungsanker haengt an **jedem** fehlgeschlagenen Speichern,
* nicht nur am Wettlauf zweier Redakteure. Wer eine Stunde an einer
* Vorlage gesessen hat, soll seinen Aufbau herauskopieren koennen,
* egal woran es lag — an einer abgelaufenen Anmeldung, an einem
* leeren Pflichtfeld oder an der Datenbank.
*/}
{speicherErgebnis.fehler ? (
<div className="flex flex-col gap-2">
{konflikt ? (
<p className="max-w-prose text-sm text-nebentext">
Die Eingaben stehen noch hier und sind nicht verloren — ein Neuladen dieser Seite würde
sie allerdings verwerfen. Am besten den Entwurf in einem zweiten Tab öffnen, die fremde
Fassung ansehen und die eigenen Änderungen dort nachtragen.
</p>
) : (
<p className="max-w-prose text-sm text-nebentext">
Die Eingaben stehen noch hier und sind nicht verloren — ein Neuladen dieser Seite würde
sie allerdings verwerfen.
</p>
)}
<details>
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
Aufbau als JSON herauskopieren
</summary>
<textarea
readOnly
value={aktuellesJson}
rows={6}
className="mono mt-1 w-full rounded-karte border border-linie bg-grund p-3 text-xs text-nebentext"
/>
</details>
</div>
) : null}
</form>
</>
)}
</div>
);
}
@@ -0,0 +1,80 @@
"use client";
import { Kennzeichen } from "@/components/vorlagen/editor-felder";
import { platzhalterNachQuelle } from "@/lib/editor-aufbau";
import { QUELLEN_TEXT, type PlatzhalterQuelle } from "@/lib/platzhalter";
/**
* Welche Platzhalter zur Verfuegung stehen (#010).
*
* Eingeklappt, weil die Liste lang ist und nur gebraucht wird, wenn man einen
* Platzhalter sucht. Gruppiert nach Quelle, damit sichtbar ist, woraus ein Wert
* kommt — und was im Modus Intern leer bleibt.
*/
export function PlatzhalterHilfe({ variablen }: { variablen: readonly string[] }) {
const gruppen = platzhalterNachQuelle();
const quellen = Object.entries(QUELLEN_TEXT) as readonly [PlatzhalterQuelle, string][];
return (
<details className="rounded-karte border border-linie bg-flaeche">
<summary className="flex min-h-11 cursor-pointer list-none items-center px-4 text-sm text-nebentext hover:text-text md:min-h-12">
Welche Platzhalter gibt es?
</summary>
<div className="flex flex-col gap-5 px-4 pt-1 pb-4">
<p className="max-w-prose text-sm text-nebentext">
Ein Platzhalter steht als <code className="mono">{"{{schluessel}}"}</code> im Titel, im Hilfetext
oder im Befehl und wird beim Abarbeiten ersetzt. Leerzeichen in den Klammern sind erlaubt.
</p>
<div>
<h3 className="text-sm font-semibold">Variablen dieser Vorlage</h3>
{variablen.length === 0 ? (
<p className="mt-1 text-sm text-nebentext">Noch keine.</p>
) : (
<ul className="mt-2 flex flex-wrap gap-2">
{variablen.map((schluessel) => (
<li key={schluessel}>
<code className="mono rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-text">
{`{{${schluessel}}}`}
</code>
</li>
))}
</ul>
)}
</div>
{quellen.map(([quelle, text]) => {
const eintraege = gruppen.get(quelle) ?? [];
if (eintraege.length === 0) return null;
return (
<div key={quelle}>
<h3 className="text-sm font-semibold">{text}</h3>
<ul className="mt-2 flex flex-col gap-1.5">
{eintraege.map((platzhalter) => (
<li
key={platzhalter.schluessel}
className="flex flex-col gap-1 sm:flex-row sm:items-baseline sm:gap-3"
>
<code className="mono shrink-0 text-xs text-akzent">
{`{{${platzhalter.schluessel}}}`}
</code>
<span className="text-xs text-nebentext">
{platzhalter.erklaerung}
{platzhalter.nurDienstleistung ? (
<>
{" "}
<Kennzeichen>nur im Modus Dienstleistung</Kennzeichen>
</>
) : null}
</span>
</li>
))}
</ul>
</div>
);
})}
</div>
</details>
);
}
+224
View File
@@ -0,0 +1,224 @@
"use client";
import { useState } from "react";
import { ChevronDown, ExternalLink, Eye } from "lucide-react";
import { Markdown } from "@/components/markdown";
import { Kennzeichen } from "@/components/vorlagen/editor-felder";
import {
BEISPIEL_PASSWORT,
STANDARD_SHELL,
abschnittNummer,
beispielwerte,
mitBeispielwerten,
verwendetePlatzhalter,
type EditorAufbau,
type EditorSchritt,
} from "@/lib/editor-aufbau";
/**
* Die Vorlage so, wie sie im Lauf aussieht (#010).
*
* Reine Darstellung: jedes Feld ist abgeschaltet, nichts wird gespeichert, und
* die Platzhalter sind mit erfundenen Beispielwerten gefuellt. Das steht auch
* oben dran — sonst haelt jemand den Beispielnamen fuer einen echten Datensatz.
*
* Fuer <code>generiertes_passwort</code> entsteht hier **kein** Passwort. Es wird
* je Anzeige neu erzeugt und nirgends gespeichert (#012); eine Vorschau, die
* eines erzeugte, waere der erste Ort, an dem eines an einer unerwarteten Stelle
* steht.
*/
const FELD_AUS = "min-h-11 w-full rounded-karte border border-linie bg-grund px-3 text-sm text-nebentext";
function SchrittFeld({ schritt, werte }: { schritt: EditorSchritt; werte: Record<string, string> }) {
switch (schritt.typ) {
case "HAKEN":
return (
<label className="flex min-h-11 items-center gap-2 text-sm text-nebentext">
<input type="checkbox" disabled className="size-5 accent-akzent" />
erledigt
</label>
);
case "TEXT":
return schritt.konfig.mehrzeilig === true ? (
<textarea disabled rows={3} className={`${FELD_AUS} py-2`} />
) : (
<input disabled className={FELD_AUS} />
);
case "ZAHL":
return (
<div className="flex items-center gap-2">
<input type="number" disabled className={`${FELD_AUS} sm:max-w-40`} />
{schritt.konfig.einheit ? (
<span className="text-sm text-nebentext">{schritt.konfig.einheit}</span>
) : null}
{schritt.konfig.min !== undefined || schritt.konfig.max !== undefined ? (
<span className="text-xs text-nebentext">
{schritt.konfig.min !== undefined ? `ab ${schritt.konfig.min}` : ""}
{schritt.konfig.min !== undefined && schritt.konfig.max !== undefined ? " " : ""}
{schritt.konfig.max !== undefined ? `bis ${schritt.konfig.max}` : ""}
</span>
) : null}
</div>
);
case "AUSWAHL":
return (
<select disabled className={`${FELD_AUS} sm:max-w-72`}>
<option>{schritt.konfig.optionen?.[0] ?? "— keine Option hinterlegt —"}</option>
</select>
);
case "DATUM":
return <input type="date" disabled className={`${FELD_AUS} sm:max-w-48`} />;
case "BEFEHL":
return (
<div className="flex flex-col gap-2">
<Kennzeichen>{schritt.konfig.shell ?? STANDARD_SHELL}</Kennzeichen>
<pre className="mono overflow-x-auto rounded-karte border border-linie bg-grund p-3 text-xs whitespace-pre-wrap">
{mitBeispielwerten(schritt.konfig.befehl ?? "", werte) || "— kein Befehl hinterlegt —"}
</pre>
</div>
);
default:
return null;
}
}
/**
* Die Vorschau haengt an einem Schalter und wird nur dann gebaut, wenn sie
* jemand sehen will.
*
* Zwei Gruende, beide gemessen: sie macht bei 390 px rund 1 500 px Seitenhoehe
* aus, und sie rechnet bei **jedem Tastendruck** die ganze Vorlage samt Markdown
* neu. Bei einer Vorlage mit 150 Schritten waere das bei jedem Buchstaben
* spuerbar. Zugeklappt wird sie nicht nur ausgeblendet, sondern gar nicht erst
* gerendert — sie haelt keinen Zustand, es geht also nichts verloren.
*/
/** Nur `http` und `https` – dieselbe Regel, die `SchrittSchema` beim Speichern anwendet. */
function istWebAdresse(roh: string): boolean {
const wert = roh.trim();
return /^https?:\/\//iu.test(wert);
}
export function Vorschau({ aufbau, beispielDatum }: { aufbau: EditorAufbau; beispielDatum: string }) {
const [offen, setzeOffen] = useState(false);
return (
<section className="flex flex-col gap-4 rounded-karte border border-linie bg-flaeche p-4">
<button
type="button"
onClick={() => setzeOffen((vorher) => !vorher)}
aria-expanded={offen}
className="flex min-h-11 items-center gap-3 text-left md:min-h-10"
>
<ChevronDown
className={`size-4 shrink-0 text-nebentext ${offen ? "" : "-rotate-90"}`}
strokeWidth={2}
aria-hidden="true"
/>
<Eye className="size-[18px] shrink-0 text-akzent" strokeWidth={1.75} aria-hidden="true" />
<h2 className="text-lg font-semibold">Vorschau</h2>
<span className="text-sm text-nebentext">mit Beispielwerten</span>
</button>
{offen ? <VorschauInhalt aufbau={aufbau} beispielDatum={beispielDatum} /> : null}
</section>
);
}
function VorschauInhalt({ aufbau, beispielDatum }: { aufbau: EditorAufbau; beispielDatum: string }) {
const werte = beispielwerte(aufbau.variablen, beispielDatum);
const mitPasswort = verwendetePlatzhalter(aufbau).includes("generiertes_passwort");
return (
<>
<div>
<p className="max-w-prose text-sm text-nebentext">
So sieht die Vorlage im Lauf aus. Alle Platzhalter sind mit{" "}
<span className="text-faellig">erfundenen Beispielwerten</span> gefüllt, die Felder sind
abgeschaltet, und nichts davon wird gespeichert.
</p>
{mitPasswort ? (
<p className="mt-2 max-w-prose text-sm text-nebentext">
<code className="mono">{"{{generiertes_passwort}}"}</code> steht hier als{" "}
<code className="mono">{BEISPIEL_PASSWORT}</code>. Das echte Passwort entsteht erst beim
Abarbeiten, wird bei jeder Anzeige neu erzeugt und nirgends gespeichert.
</p>
) : null}
</div>
{aufbau.variablen.length > 0 ? (
<div className="rounded-karte border border-linie bg-flaeche-hoch p-3">
<h3 className="text-sm font-semibold">Beim Start abgefragt</h3>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
{aufbau.variablen.map((variable) => (
<label key={variable.kennung} className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">
{variable.beschriftung || variable.schluessel || "ohne Beschriftung"}
{variable.pflicht ? <span className="text-fehler"> *</span> : null}
</span>
<input disabled value={werte[variable.schluessel.trim()] ?? ""} className={FELD_AUS} />
</label>
))}
</div>
</div>
) : null}
{aufbau.abschnitte.length === 0 ? (
<p className="text-sm text-nebentext">Noch kein Abschnitt — es gibt nichts abzuarbeiten.</p>
) : (
<ol className="flex flex-col gap-5">
{aufbau.abschnitte.map((abschnitt, index) => (
<li key={abschnitt.kennung}>
<h3 className="flex items-baseline gap-2 text-base font-semibold">
<span className="zahl text-akzent">{abschnittNummer(index)}</span>
<span className="text-nebentext">·</span>
<span>{mitBeispielwerten(abschnitt.titel, werte) || "Ohne Titel"}</span>
</h3>
<ul className="mt-3 flex flex-col gap-4">
{abschnitt.schritte.map((schritt) => (
<li key={schritt.kennung} className="flex flex-col gap-2 border-l border-linie pl-3">
<p className="text-sm">
{mitBeispielwerten(schritt.titel, werte) || "Ohne Titel"}
{schritt.pflicht ? (
<span className="text-fehler" title="Pflichtschritt">
{" *"}
</span>
) : null}
</p>
{schritt.hilfetext.trim() === "" ? null : (
<Markdown text={mitBeispielwerten(schritt.hilfetext, werte)} />
)}
<SchrittFeld schritt={schritt} werte={werte} />
{/*
* Nur http und https werden verlinkt. Im Editor ist der
* Doku-Link noch ungeprueft — das Schema sieht ihn erst
* beim Speichern. Ein `data:`-Verweis in der Vorschau waere
* die einzige Stelle, an der er anklickbar wuerde.
*/}
{!istWebAdresse(schritt.dokuUrl) ? null : (
<a
href={schritt.dokuUrl.trim()}
target="_blank"
rel="noreferrer"
className="flex min-h-11 items-center gap-1.5 text-sm text-akzent underline underline-offset-2 md:min-h-10"
>
<ExternalLink className="size-4" strokeWidth={1.75} aria-hidden="true" />
Anleitung
</a>
)}
</li>
))}
</ul>
</li>
))}
</ol>
)}
</>
);
}
+432
View File
@@ -0,0 +1,432 @@
import { EINGEBAUTE_PLATZHALTER, type EingebauterPlatzhalter } from "@/lib/platzhalter";
import {
SCHRITT_TYPEN,
VARIABLEN_TYPEN,
VariableSchema,
platzhalterDerStruktur,
platzhalterFinden,
type SchrittKonfig,
type Struktur,
} from "@/lib/vorlagen-format";
/**
* Der Aufbau einer Version, wie ihn der Editor im Zustand haelt (#010).
*
* Framework-frei und ohne React, damit sich Umsortieren, Nummerierung, der
* Umgang mit `konfig` beim Typwechsel und die Beispielwerte einzeln pruefen
* lassen — fuer Komponenten gibt es keine DOM-Testumgebung.
*
* Unterschied zur `Struktur` aus `vorlagen-format.ts`: jeder Eintrag traegt eine
* `kennung`. Die Reihenfolge einer Liste ist die fachliche Reihenfolge, aber
* React und `@dnd-kit` brauchen einen Schluessel, der beim Umsortieren am
* Eintrag bleibt und nicht am Index haengt. Die Kennung entsteht im Browser und
* wird nie gespeichert.
*/
export type SchrittTyp = (typeof SCHRITT_TYPEN)[number];
export type VariablenTyp = (typeof VARIABLEN_TYPEN)[number];
export type EditorSchritt = {
readonly kennung: string;
readonly titel: string;
/** Leer statt `undefined`: ein Formularfeld hat immer einen Wert. */
readonly hilfetext: string;
readonly typ: SchrittTyp;
readonly pflicht: boolean;
readonly konfig: SchrittKonfig;
readonly dokuUrl: string;
};
export type EditorAbschnitt = {
readonly kennung: string;
readonly titel: string;
readonly schritte: readonly EditorSchritt[];
};
export type EditorVariable = {
readonly kennung: string;
readonly schluessel: string;
readonly beschriftung: string;
readonly typ: VariablenTyp;
readonly optionen: readonly string[];
readonly pflicht: boolean;
};
export type EditorAufbau = {
readonly variablen: readonly EditorVariable[];
readonly abschnitte: readonly EditorAbschnitt[];
};
let zaehler = 0;
/**
* Eine Kennung, die nur im Browser gilt.
*
* Sie dient als React-`key` und als `id` fuer `@dnd-kit` und wird **nie** in ein
* Attribut gerendert. Deshalb darf sie sich zwischen Vorrendern auf dem Server
* und Hydrieren im Browser unterscheiden — der Zaehler steht im Modul und laeuft
* im Serverprozess ueber alle Anfragen weiter, im Browser faengt er bei null an.
* Gebraucht wird nur Eindeutigkeit innerhalb eines Editors, und die liefert er.
*/
export function neueKennung(praefix: string): string {
zaehler += 1;
return `${praefix}-${zaehler}`;
}
/** Die angezeigte Abschnittsnummer, `01` fuer den ersten. Aus der Position, nie gespeichert. */
export function abschnittNummer(index: number): string {
return String(index + 1).padStart(2, "0");
}
/**
* Eine vorangestellte Nummer aus einem Abschnittstitel entfernen.
*
* Die Nummer wird aus der Position abgeleitet und daneben angezeigt. Traegt ein
* importierter Titel sie zusaetzlich im Text — `01 · Vorbereitung`, wie es die
* Doku bisher vorschlug —, stuende sonst „01 · 01 · Vorbereitung“ da, und beim
* Verschieben wuerde die falsche Nummer mitwandern.
*
* Erkannt wird **nur** der Mittelpunkt als Trenner. Mit `.`, `)` oder `-` waere
* aus „2. Etage prüfen“ stillschweigend „Etage prüfen“ geworden: echter
* Textverlust an einer Stelle, die niemand kontrolliert.
*
* Und auch beim Mittelpunkt muss hinter dem Trenner etwas stehen, das weder
* Ziffer noch Leerraum ist: „24 · 7 Bereitschaft“ ist eine Servicezeit und keine
* Nummerierung. Der Leerraum gehoert in die Bedingung, weil `\s*` sonst
* zurueckspringt und die Vorausschau am Leerzeichen statt an der Ziffer prueft.
* Ohne diese Bedingung waere daraus „7 Bereitschaft“ geworden — derselbe stille
* Verlust, nur seltener.
*/
export function titelOhneNummer(titel: string): string {
return titel.replace(/^\s*\d{1,2}\s*·\s*(?=[^\s\d])/u, "");
}
/**
* Einen Eintrag von einer Position auf eine andere schieben.
*
* Gibt die unveraenderte Liste zurueck, wenn nichts zu tun oder der Index
* ausserhalb ist — so koennen die Knoepfe „nach oben“ und „nach unten“ ohne
* eigene Randpruefung rechnen.
*/
export function umsortieren<T>(liste: readonly T[], von: number, nach: number): readonly T[] {
if (von === nach) return liste;
if (von < 0 || nach < 0 || von >= liste.length || nach >= liste.length) return liste;
const kopie = [...liste];
const [eintrag] = kopie.splice(von, 1);
// Kann nicht eintreten, der Index ist oben geprueft; TypeScript weiss das nicht.
if (eintrag === undefined) return liste;
kopie.splice(nach, 0, eintrag);
return kopie;
}
/**
* Welche `konfig`-Schluessel zu welchem Schritt-Typ gehoeren.
*
* Dieselbe Aufteilung, die `docs/vorlagen-format.md` beschreibt — hier als
* Daten, damit das typabhaengige Formular und die Aufraeumung beim Typwechsel
* aus einer Quelle lesen.
*/
export const KONFIG_SCHLUESSEL: Record<SchrittTyp, readonly string[]> = {
HAKEN: [],
TEXT: ["mehrzeilig"],
ZAHL: ["min", "max", "einheit"],
AUSWAHL: ["optionen"],
DATUM: [],
BEFEHL: ["shell", "befehl"],
};
const ALLE_KONFIG_SCHLUESSEL: ReadonlySet<string> = new Set(Object.values(KONFIG_SCHLUESSEL).flat());
/**
* Die `konfig` beim Wechsel des Schritt-Typs aufraeumen.
*
* Entfernt wird, was einem der bekannten Typen gehoert, aber nicht dem neuen:
* bliebe der `befehl` an einem Schritt haengen, der inzwischen `HAKEN` ist,
* waere er unsichtbarer Ballast — und seine Platzhalter zaehlten weiter zur
* Warnung, ohne dass irgendwo ein Feld zu sehen ist, das man korrigieren
* koennte. Das Format erlaubt fremde Schluessel ausdruecklich (sie sollen einen
* Rundlauf durch eine aeltere Fassung ueberleben), deshalb bleibt alles
* Unbekannte stehen: es gehoert keinem Typ, den diese Fassung kennt, und nichts
* hier wertet es aus.
*/
export function konfigFuerTyp(konfig: SchrittKonfig, typ: SchrittTyp): SchrittKonfig {
const erlaubt = new Set(KONFIG_SCHLUESSEL[typ]);
const gesaeubert: Record<string, unknown> = {};
for (const [schluessel, wert] of Object.entries(konfig)) {
if (erlaubt.has(schluessel) || !ALLE_KONFIG_SCHLUESSEL.has(schluessel)) {
gesaeubert[schluessel] = wert;
}
}
return gesaeubert as SchrittKonfig;
}
/**
* Beschriftungen der Typen — in `src/lib`, weil Editor und Vorschau sie im
* Browser brauchen und nichts aus `src/server` dorthin gehoert.
*/
export const SCHRITT_TYP_TEXT: Record<SchrittTyp, string> = {
HAKEN: "Haken",
TEXT: "Text",
ZAHL: "Zahl",
AUSWAHL: "Auswahl",
DATUM: "Datum",
BEFEHL: "Befehl",
};
export const VARIABLEN_TYP_TEXT: Record<VariablenTyp, string> = {
TEXT: "Text",
ZAHL: "Zahl",
AUSWAHL: "Auswahl",
DATUM: "Datum",
};
/**
* Die Shell, die ein `BEFEHL`-Schritt ohne Angabe bedeutet.
*
* Formular und Vorschau greifen auf dieselbe Annahme zurueck, damit die Vorschau
* nicht etwas anderes zeigt als das Feld darueber. Gespeichert wird sie, sobald
* jemand den Typ auf `BEFEHL` stellt; bei einer importierten Datei ohne `shell`
* bleibt sie eine Anzeige-Annahme.
*/
export const STANDARD_SHELL = "powershell";
/** Ein leerer Schritt, wie ihn „Schritt hinzufügen“ anlegt. */
export function neuerSchritt(): EditorSchritt {
return {
kennung: neueKennung("schritt"),
titel: "",
hilfetext: "",
typ: "HAKEN",
pflicht: false,
konfig: {},
dokuUrl: "",
};
}
export function neuerAbschnitt(): EditorAbschnitt {
return { kennung: neueKennung("abschnitt"), titel: "", schritte: [] };
}
export function neueVariable(): EditorVariable {
return {
kennung: neueKennung("variable"),
schluessel: "",
beschriftung: "",
typ: "TEXT",
optionen: [],
pflicht: false,
};
}
/** Die geladene Struktur in den Editor-Zustand uebernehmen. */
export function alsEditorAufbau(struktur: Struktur): EditorAufbau {
return {
variablen: struktur.variablen.map((variable) => ({
kennung: neueKennung("variable"),
schluessel: variable.schluessel,
beschriftung: variable.beschriftung,
typ: variable.typ,
optionen: [...variable.optionen],
pflicht: variable.pflicht,
})),
abschnitte: struktur.abschnitte.map((abschnitt) => ({
kennung: neueKennung("abschnitt"),
// Die Nummer gehoert zur Position, nicht in den Titel — siehe titelOhneNummer.
titel: titelOhneNummer(abschnitt.titel),
schritte: abschnitt.schritte.map((schritt) => ({
kennung: neueKennung("schritt"),
titel: schritt.titel,
hilfetext: schritt.hilfetext ?? "",
typ: schritt.typ,
pflicht: schritt.pflicht,
konfig: schritt.konfig,
dokuUrl: schritt.dokuUrl ?? "",
})),
})),
};
}
/**
* Der Zustand zurueck in die Form, die gespeichert und exportiert wird.
*
* Kennungen fallen weg, leere Textfelder werden zu `undefined` und damit im
* JSON weggelassen — ein leerer Doku-Link ist keine Adresse, und das Schema
* wuerde ihn zu Recht beanstanden.
*/
export function alsStruktur(aufbau: EditorAufbau): Struktur {
return {
variablen: aufbau.variablen.map((variable) => ({
schluessel: variable.schluessel.trim(),
beschriftung: variable.beschriftung,
typ: variable.typ,
// Optionen gehoeren nur zu AUSWAHL; sonst waeren sie beim Typwechsel
// derselbe unsichtbare Ballast wie eine nicht aufgeraeumte konfig.
optionen: variable.typ === "AUSWAHL" ? [...variable.optionen] : [],
pflicht: variable.pflicht,
})),
abschnitte: aufbau.abschnitte.map((abschnitt) => ({
titel: abschnitt.titel,
schritte: abschnitt.schritte.map((schritt) => ({
titel: schritt.titel,
hilfetext: schritt.hilfetext.trim() === "" ? undefined : schritt.hilfetext,
typ: schritt.typ,
pflicht: schritt.pflicht,
konfig: schritt.konfig,
dokuUrl: schritt.dokuUrl.trim() === "" ? undefined : schritt.dokuUrl.trim(),
})),
})),
};
}
/** Alle Platzhalter, die im aktuellen Zustand vorkommen. */
export function verwendetePlatzhalter(aufbau: EditorAufbau): readonly string[] {
return platzhalterDerStruktur(alsStruktur(aufbau));
}
/**
* Ist der Variablenschluessel gueltig — und wenn nicht, warum?
*
* Fragt das Schema selbst, statt sein Muster abzuschreiben: sonst gaebe es zwei
* Regeln, und die im Editor waere die, die niemand nachzieht.
*/
export function schluesselFehler(schluessel: string): string | undefined {
const geprueft = VariableSchema.shape.schluessel.safeParse(schluessel);
if (geprueft.success) return undefined;
return geprueft.error.issues[0]?.message ?? "Der Schlüssel ist ungültig.";
}
/** Die Regel als Satz, fuer den Hinweis unter dem Feld. Kommt aus demselben Schema. */
export const SCHLUESSEL_REGEL: string = schluesselFehler("") ?? "";
/**
* Schluessel, die mehr als einmal vorkommen.
*
* Der Dienst lehnt das beim Speichern ab. Besser, es faellt schon beim Tippen
* auf — mit der doppelten Zeile im Blick.
*/
export function doppelteSchluessel(variablen: readonly EditorVariable[]): readonly string[] {
const gesehen = new Set<string>();
const doppelt = new Set<string>();
for (const variable of variablen) {
const schluessel = variable.schluessel.trim();
if (schluessel === "") continue;
if (gesehen.has(schluessel)) doppelt.add(schluessel);
gesehen.add(schluessel);
}
return [...doppelt];
}
/**
* Beispieltext fuer `{{generiertes_passwort}}`.
*
* Fest und erkennbar unecht. Ein echtes Passwort darf hier nicht entstehen: es
* wird je Anzeige neu erzeugt und nirgends gespeichert (#012), und eine
* Vorschau, die eines zeigt, faengt an, sich auf etwas zu verlassen, das es
* noch nicht gibt.
*/
export const BEISPIEL_PASSWORT = "BEISPIEL-PASSWORT-NICHT-ECHT";
/**
* Beispielwerte der eingebauten Platzhalter.
*
* Erkennbar erfunden, aber in der richtigen Form: ein `dn` sieht aus wie ein
* `dn`, damit in der Vorschau eines PowerShell-Befehls zu sehen ist, was der
* Techniker spaeter wirklich vor sich hat. `datum` fehlt hier bewusst und kommt
* von aussen — siehe `beispielwerte`.
*/
const BEISPIELWERTE: Record<string, string> = {
hostname: "NB-MUSTER-014",
seriennummer: "SN-2026-000123",
inventarnummer: "INV-004711",
ticket: "T-12345",
techniker: "Tina Technik",
kunde: "Muster GmbH",
kundenkuerzel: "MUS",
mitarbeiter: "Anna Beispiel",
benutzer: "Anna Beispiel",
vorname: "Anna",
nachname: "Beispiel",
upn: "anna.beispiel@muster.example",
sam: "a.beispiel",
mail: "anna.beispiel@muster.example",
abteilung: "Vertrieb",
titel: "Sachbearbeiterin",
vorgesetzter: "Bernd Beispiel",
vorgesetzter_sam: "b.beispiel",
dn: "CN=Anna Beispiel,OU=Vertrieb,DC=muster,DC=example",
ou: "OU=Vertrieb,DC=muster,DC=example",
gruppen: "G-Vertrieb, G-Drucker-EG",
generiertes_passwort: BEISPIEL_PASSWORT,
};
function beispielFuerVariable(variable: EditorVariable, beispielDatum: string): string {
switch (variable.typ) {
case "AUSWAHL":
return variable.optionen[0] ?? "Beispielauswahl";
case "ZAHL":
return "42";
case "DATUM":
return beispielDatum;
default:
return `Beispiel-${variable.schluessel.trim() || "wert"}`;
}
}
/**
* Die Beispielwerte fuer die Vorschau.
*
* `beispielDatum` kommt von der Serverseite und nicht aus `new Date()`: der
* Editor wird serverseitig vorgerendert, und zwei verschiedene Uhrzeiten in
* denselben Text ergaeben einen Hydrierungsfehler.
*
* Gibt es einen Schluessel als Variable **und** als eingebauten Wert — etwa
* `abteilung` —, zeigt die Vorschau den Beispielwert der Variablen: sie wird im
* Startformular des Laufs abgefragt und ist damit die konkretere Angabe. Fuer
* Beispieltexte ist der Unterschied ohnehin nur ein anderer erfundener Name.
*/
export function beispielwerte(
variablen: readonly EditorVariable[],
beispielDatum: string,
): Record<string, string> {
const werte: Record<string, string> = { ...BEISPIELWERTE, datum: beispielDatum };
for (const variable of variablen) {
const schluessel = variable.schluessel.trim();
if (schluessel !== "") werte[schluessel] = beispielFuerVariable(variable, beispielDatum);
}
return werte;
}
/**
* Platzhalter im Text durch Beispielwerte ersetzen.
*
* Ein unbekannter Platzhalter bleibt als `{{schluessel}}` stehen — genau so wie
* im Lauf, und die Warnung im Editor sagt dazu, was fehlt. Die Namen kommen aus
* `platzhalterFinden` und bestehen damit nur aus Kleinbuchstaben, Ziffern und
* Unterstrich; im **Suchausdruck** ist daran nichts zu entschaerfen.
*
* Im **Ersatztext** sehr wohl: `String.replace` deutet dort `$&`, `` $` ``, `$'`
* und `$1` als Rueckverweise. Ein Auswahlwert „Kosten $& Nutzen“ haette den
* gefundenen Platzhalter wieder eingesetzt. Eine Ersatzfunktion liefert den Wert
* unveraendert — wichtig spaetestens fuer #012, wo derselbe Weg einen
* PowerShell-Befehl zusammensetzt.
*/
export function mitBeispielwerten(text: string, werte: Record<string, string>): string {
let ergebnis = text;
for (const name of platzhalterFinden(text)) {
const wert = werte[name];
if (wert === undefined) continue;
ergebnis = ergebnis.replace(new RegExp(`\\{\\{\\s*${name}\\s*\\}\\}`, "g"), () => wert);
}
return ergebnis;
}
/** Die eingebauten Platzhalter nach Quelle gruppiert — fuer die Hilfe im Editor. */
export function platzhalterNachQuelle(): ReadonlyMap<string, readonly EingebauterPlatzhalter[]> {
const gruppen = new Map<string, EingebauterPlatzhalter[]>();
for (const platzhalter of EINGEBAUTE_PLATZHALTER) {
const vorhanden = gruppen.get(platzhalter.quelle);
if (vorhanden) vorhanden.push(platzhalter);
else gruppen.set(platzhalter.quelle, [platzhalter]);
}
return gruppen;
}
+1 -1
View File
@@ -25,7 +25,7 @@ export type NavEintrag = {
export const NAVIGATION: readonly NavEintrag[] = [
{ pfad: "/", beschriftung: "Übersicht", symbol: "LayoutDashboard", verfuegbar: true },
{ pfad: "/laeufe", beschriftung: "Läufe", symbol: "ListChecks", verfuegbar: false, ausIssue: 13 },
{ pfad: "/vorlagen", beschriftung: "Vorlagen", symbol: "FileText", verfuegbar: false, ausIssue: 10 },
{ pfad: "/vorlagen", beschriftung: "Vorlagen", symbol: "FileText", verfuegbar: true },
{
pfad: "/mitarbeiter",
beschriftung: "Mitarbeiter & Geräte",
+115
View File
@@ -0,0 +1,115 @@
/**
* Die eingebauten Platzhalter (#010, aufgeloest in #012).
*
* Ein Platzhalter `{{schluessel}}` in Titel, Hilfetext oder Befehl wird beim
* Abarbeiten eines Laufs ersetzt. Woraus, steht hier: entweder aus einer
* Variablen der Vorlage oder aus einer der eingebauten Quellen. Ein Schluessel,
* der zu keiner von beiden passt, bleibt im Lauf als `{{schluessel}}` stehen —
* sichtbar, aber nutzlos. Deshalb warnt der Editor davor.
*
* Die Liste ist bewusst Daten und kein Code: der Editor zeigt sie als Hilfe an,
* die Warnung vergleicht dagegen, und #012 baut die Aufloesung darauf auf. Eine
* zweite Aufzaehlung an anderer Stelle wuerde unweigerlich auseinanderlaufen.
*/
export type PlatzhalterQuelle = "GERAET_LAUF" | "MITARBEITER" | "ABTEILUNG" | "BESONDERS";
export type EingebauterPlatzhalter = {
readonly schluessel: string;
readonly quelle: PlatzhalterQuelle;
readonly erklaerung: string;
/** Nur im Modus DIENSTLEISTUNG gefuellt; im Modus INTERN bleibt der Wert leer. */
readonly nurDienstleistung?: true;
};
export const EINGEBAUTE_PLATZHALTER: readonly EingebauterPlatzhalter[] = [
// Aus Geraet und Lauf
{ schluessel: "hostname", quelle: "GERAET_LAUF", erklaerung: "Hostname des Geräts im Lauf" },
{ schluessel: "seriennummer", quelle: "GERAET_LAUF", erklaerung: "Seriennummer des Geräts" },
{ schluessel: "inventarnummer", quelle: "GERAET_LAUF", erklaerung: "Inventarnummer des Geräts" },
{ schluessel: "ticket", quelle: "GERAET_LAUF", erklaerung: "Ticketnummer am Lauf" },
{ schluessel: "datum", quelle: "GERAET_LAUF", erklaerung: "heutiges Datum" },
{ schluessel: "techniker", quelle: "GERAET_LAUF", erklaerung: "Name des Bearbeiters" },
{
schluessel: "kunde",
quelle: "GERAET_LAUF",
erklaerung: "Name des Kunden",
nurDienstleistung: true,
},
{
schluessel: "kundenkuerzel",
quelle: "GERAET_LAUF",
erklaerung: "Kürzel des Kunden",
nurDienstleistung: true,
},
// Aus dem Mitarbeiter
{ schluessel: "mitarbeiter", quelle: "MITARBEITER", erklaerung: "vollständiger Name" },
{ schluessel: "benutzer", quelle: "MITARBEITER", erklaerung: "gleichbedeutend mit mitarbeiter" },
{ schluessel: "vorname", quelle: "MITARBEITER", erklaerung: "Vorname" },
{ schluessel: "nachname", quelle: "MITARBEITER", erklaerung: "Nachname" },
{ schluessel: "upn", quelle: "MITARBEITER", erklaerung: "User Principal Name" },
{ schluessel: "sam", quelle: "MITARBEITER", erklaerung: "sAMAccountName, höchstens 20 Zeichen" },
{ schluessel: "mail", quelle: "MITARBEITER", erklaerung: "Mail-Adresse" },
{ schluessel: "abteilung", quelle: "MITARBEITER", erklaerung: "Name der Abteilung" },
{ schluessel: "titel", quelle: "MITARBEITER", erklaerung: "Funktion oder Titel" },
{ schluessel: "vorgesetzter", quelle: "MITARBEITER", erklaerung: "Name des Vorgesetzten" },
{ schluessel: "vorgesetzter_sam", quelle: "MITARBEITER", erklaerung: "sAMAccountName des Vorgesetzten" },
{ schluessel: "dn", quelle: "MITARBEITER", erklaerung: "Distinguished Name im Verzeichnis" },
// Aus der Abteilung
{
schluessel: "ou",
quelle: "ABTEILUNG",
erklaerung: "Ziel-Container: Eltern-Container des dn, sonst die Ziel-OU der Abteilung",
},
{ schluessel: "gruppen", quelle: "ABTEILUNG", erklaerung: "Standardgruppen, kommagetrennt" },
// Besonders
{
schluessel: "generiertes_passwort",
quelle: "BESONDERS",
erklaerung:
"20 Zeichen, bei jeder Anzeige neu erzeugt und nirgends gespeichert — auch nicht im Protokoll",
},
];
export const QUELLEN_TEXT: Record<PlatzhalterQuelle, string> = {
GERAET_LAUF: "Gerät und Lauf",
MITARBEITER: "Mitarbeiter",
ABTEILUNG: "Abteilung",
BESONDERS: "Besonders",
};
const EINGEBAUTE_SCHLUESSEL: ReadonlySet<string> = new Set(
EINGEBAUTE_PLATZHALTER.map((platzhalter) => platzhalter.schluessel),
);
export function istEingebaut(schluessel: string): boolean {
return EINGEBAUTE_SCHLUESSEL.has(schluessel);
}
/**
* Die Platzhalter, die weder eingebaut noch als Variable definiert sind.
*
* Das ist eine **Warnung**, keine Ablehnung: ein Tippfehler faellt so im Editor
* auf, statt erst dem Techniker im Lauf. Speichern und Veroeffentlichen bleiben
* erlaubt — es kann gute Gruende geben, einen Platzhalter stehen zu lassen, etwa
* wenn die Variable noch kommt.
*/
export function unbekanntePlatzhalter(
verwendete: readonly string[],
variablenSchluessel: readonly string[],
): readonly string[] {
const bekannt = new Set(variablenSchluessel);
return verwendete.filter((schluessel) => !bekannt.has(schluessel) && !istEingebaut(schluessel));
}
/** Variablen, die definiert sind, aber nirgends verwendet werden. */
export function unbenutzteVariablen(
verwendete: readonly string[],
variablenSchluessel: readonly string[],
): readonly string[] {
const benutzt = new Set(verwendete);
return variablenSchluessel.filter((schluessel) => !benutzt.has(schluessel));
}
+25
View File
@@ -104,6 +104,9 @@ export const AbschnittSchema = z.object({
.default([]),
});
/** Ein Abschnitt so, wie ihn ein Editor oder eine Datei liefert – vor dem Pruefen. */
export type AbschnittEingabe = z.input<typeof AbschnittSchema>;
export const VariableSchema = z.object({
/**
* Der Schluessel steht als `{{schluessel}}` in Titeln, Hilfetexten und
@@ -176,6 +179,28 @@ export type Austausch = z.infer<typeof AustauschSchema>;
* soll, wenn ein Platzhalter zu keiner Variablen und zu keinem eingebauten Wert
* gehoert. Das Ersetzen selbst kommt in #012.
*/
/**
* Aus einem Zod-Pfad eine Stelle machen, die man auf dem Bildschirm wiederfindet.
*
* `struktur.abschnitte.2.schritte.0.titel` ist fuer den Aufbau richtig und fuer
* den Redakteur nutzlos — er muesste von null an zaehlen und wuesste immer noch
* nicht, ob „2“ den dritten Abschnitt meint. Daraus wird „Abschnitt 03,
* Schritt 1: “. Der Rest der Meldung kommt unveraendert aus dem Schema.
*/
export function stelleBenennen(pfad: readonly PropertyKey[]): string {
const teile: string[] = [];
for (let i = 0; i < pfad.length; i += 1) {
const name = String(pfad[i]);
const index = Number(pfad[i + 1]);
if (!Number.isInteger(index)) continue;
if (name === "abschnitte") teile.push(`Abschnitt ${String(index + 1).padStart(2, "0")}`);
else if (name === "schritte") teile.push(`Schritt ${index + 1}`);
else if (name === "variablen") teile.push(`Variable ${index + 1}`);
else if (name === "optionen") teile.push(`Option ${index + 1}`);
}
return teile.length > 0 ? `${teile.join(", ")}: ` : "";
}
export function platzhalterFinden(text: string): readonly string[] {
const gefunden = new Set<string>();
for (const treffer of text.matchAll(/\{\{\s*([a-z][a-z0-9_]*)\s*\}\}/g)) {
+47 -17
View File
@@ -33,6 +33,40 @@ export function istOeffentlich(pfad: string): boolean {
return OEFFENTLICH.some((eintrag) => pfad === eintrag || pfad.startsWith(`${eintrag}/`));
}
/**
* Die Content-Security-Policy als reine Funktion — damit sie sich testen laesst.
*
* Im Entwicklungsmodus braucht es zwei Lockerungen, und nur dort:
*
* - **`unsafe-eval`**: React und Turbopack werten im Entwicklungsmodus Code zur
* Laufzeit aus (Fast Refresh, Aufrufketten ueber Umgebungsgrenzen hinweg).
* Ohne diese Erlaubnis **hydriert die Seite gar nicht** — sie sieht richtig
* aus, aber kein Knopf tut etwas. Das ist der heimtueckische Teil: ein
* Screenshot sieht aus wie immer, und erst ein Klick faellt auf.
* - **`ws:` und `wss:`**: Fast Refresh haelt eine WebSocket-Verbindung.
*
* Im gebauten Stand braucht React beides nicht. Die ausgelieferte Anwendung
* bekommt deshalb weiterhin die strenge Fassung — ein Test haelt das fest,
* damit die Lockerung nicht eines Tages mitgeliefert wird.
*/
export function sicherheitsrichtlinie(nonce: string, entwicklung: boolean): string {
return [
`default-src 'self'`,
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${entwicklung ? " 'unsafe-eval'" : ""}`,
// Tailwind erzeugt Stilangaben zur Laufzeit, deshalb unsafe-inline fuer Stile.
`style-src 'self' 'unsafe-inline'`,
`img-src 'self' data: blob:`,
`font-src 'self'`,
// Die Kamera des Scanners liefert Bilddaten als blob:.
`media-src 'self' blob:`,
`connect-src 'self'${entwicklung ? " ws: wss:" : ""}`,
`frame-ancestors 'none'`,
`base-uri 'self'`,
`form-action 'self'`,
`object-src 'none'`,
].join("; ");
}
export function proxy(anfrage: NextRequest): NextResponse {
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
const pfad = anfrage.nextUrl.pathname;
@@ -44,21 +78,7 @@ export function proxy(anfrage: NextRequest): NextResponse {
return NextResponse.redirect(anmelden);
}
const richtlinie = [
`default-src 'self'`,
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
// Tailwind erzeugt Stilangaben zur Laufzeit, deshalb unsafe-inline fuer Stile.
`style-src 'self' 'unsafe-inline'`,
`img-src 'self' data: blob:`,
`font-src 'self'`,
// Die Kamera des Scanners liefert Bilddaten als blob:.
`media-src 'self' blob:`,
`connect-src 'self'`,
`frame-ancestors 'none'`,
`base-uri 'self'`,
`form-action 'self'`,
`object-src 'none'`,
].join("; ");
const richtlinie = sicherheitsrichtlinie(nonce, process.env.NODE_ENV !== "production");
const kopfzeilen = new Headers(anfrage.headers);
kopfzeilen.set("x-nonce", nonce);
@@ -74,6 +94,16 @@ export function proxy(anfrage: NextRequest): NextResponse {
}
export const config = {
// Statische Dateien und Bilder brauchen weder CSP-Nonce noch Tor.
matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|webp|woff2?)$).*)"],
/*
* Alles unter `_next` bleibt aussen vor, nicht nur `static` und `image`.
*
* Die CSP gehoert auf das Dokument, nicht auf jeden Programmteil, und das Tor
* schuetzt Seiten, keine Bausteine. Vorher lief der Proxy auch auf
* `/_next/hmr` — der Verbindungsaufbau von Fast Refresh ist ein
* WebSocket-Upgrade, und `NextResponse.next()` mit geaenderten Kopfzeilen
* macht daraus eine gewoehnliche Antwort. Die Verbindung scheiterte, und im
* Entwicklungsmodus hydrierte die Seite nicht mehr: sie sah richtig aus, aber
* kein Knopf tat etwas.
*/
matcher: ["/((?!_next/|favicon.ico|.*\\.(?:svg|png|webp|woff2?)$).*)"],
};
+49 -7
View File
@@ -2,9 +2,9 @@
import { revalidatePath } from "next/cache";
import { z } from "@/lib/validierung";
import { StrukturSchema } from "@/lib/vorlagen-format";
import { StrukturSchema, stelleBenennen } from "@/lib/vorlagen-format";
import { kundeZugriffPruefen, kundenEbeneSichtbar } from "@/server/mandant";
import { rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import {
VorlagenFehler,
entwurfHolenOderErzeugen,
@@ -36,10 +36,40 @@ function alsErgebnis(fehler: unknown, neutral: string): AktionsErgebnis {
if (fehler instanceof VorlagenFehler || fehler instanceof AustauschFehler) {
return { fehler: fehler.message };
}
if (fehler instanceof RechteFehler) {
return { fehler: RECHTE_MELDUNG };
}
console.error(`${neutral}:`, fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : fehler);
return { fehler: neutral };
}
const RECHTE_MELDUNG =
"Deine Anmeldung gilt nicht mehr. Melde dich in einem zweiten Tab neu an und klicke dann hier " +
"noch einmal — was du eingegeben hast, steht weiterhin auf dieser Seite.";
/**
* Die Rechtepruefung so, dass ein abgelaufenes Cookie kein Datenverlust ist.
*
* `rechtErforderlich` wirft. Wird der Wurf nicht gefangen, endet er in Nexts
* Fehlergrenze — die Seite wird ausgehaengt, und im Editor ist damit alles weg,
* was noch nicht gespeichert war. Nach zwoelf Stunden laeuft die Sitzung von
* selbst ab; das trifft genau die Person, die lange an einer Vorlage sitzt.
*
* Deshalb: fangen, und dem Benutzer sagen, was zu tun ist. Der Editor behaelt
* seinen Zustand, weil die Aktion ganz normal ein Ergebnis zurueckgibt.
*/
async function angemeldetOderMeldung(): Promise<
{ readonly benutzerId: string } | { readonly fehler: string }
> {
try {
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
return { benutzerId: benutzer.id };
} catch (fehler) {
if (fehler instanceof RechteFehler) return { fehler: RECHTE_MELDUNG };
throw fehler;
}
}
const AnlegenSchema = z.object({
name: z.string().trim().min(1, "Bitte einen Namen angeben."),
beschreibung: z.string().trim().optional(),
@@ -127,7 +157,8 @@ export async function entwurfSpeichernAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis & { readonly stand?: string }> {
await rechtErforderlich("vorlagenBearbeiten");
const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang;
const roh = formular.get("struktur");
let geparst: unknown;
@@ -145,7 +176,7 @@ export async function entwurfSpeichernAktion(
if (!eingabe.success) {
const erster = eingabe.error.issues[0];
return {
fehler: erster ? `${erster.path.join(".")}: ${erster.message}` : "Der Aufbau ist unvollständig.",
fehler: erster ? `${stelleBenennen(erster.path)}${erster.message}` : "Der Aufbau ist unvollständig.",
};
}
@@ -197,6 +228,16 @@ export async function vorlageBearbeitenAktion(
await kundeZugriffPruefen(kundeId, benutzer);
}
/*
* Ein fehlendes Feld heisst „nicht angefasst“, ein leeres heisst „fuer alle“.
*
* Im Modus Intern zeigt das Formular gar keine Kundenwahl. Ohne diese
* Unterscheidung haette jedes Speichern der Kopfdaten die Kundenzuordnung
* stillschweigend geloescht — und nach einem Wechsel zurueck in den Modus
* Dienstleistung waere die Vorlage ploetzlich fuer alle sichtbar gewesen.
*/
const kundeAngegeben = formular.has("kundeId");
try {
await vorlageBearbeiten(
eingabe.data.vorlageId,
@@ -208,7 +249,7 @@ export async function vorlageBearbeitenAktion(
.split(",")
.map((wort) => wort.trim())
.filter(Boolean),
kundeId,
...(kundeAngegeben ? { kundeId } : {}),
},
benutzer.id,
);
@@ -223,7 +264,8 @@ export async function veroeffentlichenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang;
const eingabe = z
.object({ versionId: z.string().min(1), aenderungsnotiz: z.string().trim().optional() })
.safeParse({
@@ -236,7 +278,7 @@ export async function veroeffentlichenAktion(
const version = await versionVeroeffentlichen(
eingabe.data.versionId,
eingabe.data.aenderungsnotiz ?? null,
benutzer.id,
zugang.benutzerId,
);
revalidatePath("/vorlagen");
return { erfolg: `Version ${version.version} ist veröffentlicht.` };
+82 -35
View File
@@ -1,3 +1,4 @@
import type { Prisma } from "@/generated/prisma/client";
import { VersionStatus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { platzhalterDerStruktur, type Struktur } from "@/lib/vorlagen-format";
@@ -19,6 +20,10 @@ export type VorlagenZeile = {
readonly schlagworte: readonly string[];
readonly archiviert: boolean;
readonly kundeId: string | null;
/** Name des Kunden, wenn die Vorlage kundenspezifisch ist – fuer die Anzeige. */
readonly kundeName: string | null;
/** Kennung des vorhandenen Entwurfs, damit die Liste direkt dorthin verlinkt. */
readonly entwurfVersionId: string | null;
/** Nummer der zuletzt veroeffentlichten Version, falls es eine gibt. */
readonly veroeffentlichteVersion: number | null;
readonly veroeffentlichtAm: Date | null;
@@ -51,6 +56,55 @@ async function kundenBedingung(benutzer: HandelnderBenutzer) {
return { OR: [{ kundeId: null }, { kundeId: { in: [...sichtbar] } }] };
}
/**
* Auswahl und Abbildung stehen getrennt, damit Liste und Einzelabruf garantiert
* dieselben Felder liefern – zwei Modelle fuer dieselbe Zeile laufen mit der
* Zeit auseinander.
*/
const ZEILEN_AUSWAHL = {
id: true,
name: true,
beschreibung: true,
kategorie: true,
schlagworte: true,
archiviert: true,
kundeId: true,
kunde: { select: { name: true } },
versionen: {
select: {
id: true,
version: true,
status: true,
veroeffentlichtAm: true,
_count: { select: { laeufe: true } },
},
},
} satisfies Prisma.VorlageSelect;
type VorlagenRohzeile = Prisma.VorlageGetPayload<{ select: typeof ZEILEN_AUSWAHL }>;
function zeileBauen(zeile: VorlagenRohzeile): VorlagenZeile {
const neueste = zeile.versionen
.filter((version) => version.status === VersionStatus.VEROEFFENTLICHT)
.sort((a, b) => b.version - a.version)[0];
const entwurf = zeile.versionen.find((version) => version.status === VersionStatus.ENTWURF);
return {
id: zeile.id,
name: zeile.name,
beschreibung: zeile.beschreibung,
kategorie: zeile.kategorie,
schlagworte: zeile.schlagworte,
archiviert: zeile.archiviert,
kundeId: zeile.kundeId,
kundeName: zeile.kunde?.name ?? null,
entwurfVersionId: entwurf?.id ?? null,
veroeffentlichteVersion: neueste?.version ?? null,
veroeffentlichtAm: neueste?.veroeffentlichtAm ?? null,
hatEntwurf: entwurf !== undefined,
anzahlLaeufe: zeile.versionen.reduce((summe, version) => summe + version._count.laeufe, 0),
};
}
export async function vorlagenListe(
benutzer: HandelnderBenutzer,
filter: VorlagenFilter = {},
@@ -75,44 +129,29 @@ export async function vorlagenListe(
const zeilen = await prisma.vorlage.findMany({
where: { AND: bedingungen },
orderBy: [{ archiviert: "asc" }, { name: "asc" }],
select: {
id: true,
name: true,
beschreibung: true,
kategorie: true,
schlagworte: true,
archiviert: true,
kundeId: true,
versionen: {
select: {
version: true,
status: true,
veroeffentlichtAm: true,
_count: { select: { laeufe: true } },
},
},
},
select: ZEILEN_AUSWAHL,
});
return zeilen.map(zeileBauen);
}
return zeilen.map((zeile) => {
const veroeffentlichte = zeile.versionen
.filter((v) => v.status === VersionStatus.VEROEFFENTLICHT)
.sort((a, b) => b.version - a.version);
const neueste = veroeffentlichte[0];
return {
id: zeile.id,
name: zeile.name,
beschreibung: zeile.beschreibung,
kategorie: zeile.kategorie,
schlagworte: zeile.schlagworte,
archiviert: zeile.archiviert,
kundeId: zeile.kundeId,
veroeffentlichteVersion: neueste?.version ?? null,
veroeffentlichtAm: neueste?.veroeffentlichtAm ?? null,
hatEntwurf: zeile.versionen.some((v) => v.status === VersionStatus.ENTWURF),
anzahlLaeufe: zeile.versionen.reduce((summe, v) => summe + v._count.laeufe, 0),
};
/**
* Eine einzelne Vorlage mit ihren Kopfdaten.
*
* Bewusst dasselbe Lesemodell wie die Liste: die Vorlagenseite zeigt dieselben
* Angaben, und zwei Modelle fuer dieselbe Zeile laufen mit der Zeit auseinander.
* `undefined` heisst „gibt es nicht oder darfst du nicht sehen“ — die
* Unterscheidung waere schon die Auskunft, die wir nicht geben wollen.
*/
export async function vorlageHolen(
vorlageId: string,
benutzer: HandelnderBenutzer,
): Promise<VorlagenZeile | undefined> {
const mandant = await kundenBedingung(benutzer);
const zeile = await prisma.vorlage.findFirst({
where: { id: vorlageId, ...(mandant ? mandant : {}) },
select: ZEILEN_AUSWAHL,
});
return zeile ? zeileBauen(zeile) : undefined;
}
/** Die Kategorien und Schlagworte, die tatsaechlich vorkommen – fuer die Filter. */
@@ -188,6 +227,12 @@ export type VersionMitAufbau = {
readonly vorlageName: string;
readonly version: number;
readonly status: VersionStatus;
/**
* Stand beim Laden. Der Editor schickt ihn beim Speichern zurueck; weicht er
* ab, hat jemand anderes zwischenzeitlich gespeichert (#009).
*/
readonly stand: Date;
readonly aenderungsnotiz: string | null;
readonly struktur: Struktur;
/** Platzhalter, die in Titeln, Hilfetexten und Befehlen vorkommen. */
readonly platzhalter: readonly string[];
@@ -238,6 +283,8 @@ export async function versionMitAufbau(
vorlageName: version.vorlage.name,
version: version.version,
status: version.status,
stand: version.geaendertAm,
aenderungsnotiz: version.aenderungsnotiz,
struktur,
platzhalter: platzhalterDerStruktur(struktur),
};
+2 -2
View File
@@ -1,7 +1,7 @@
import { Prisma, type Vorlage, type VorlagenVersion } from "@/generated/prisma/client";
import { VersionStatus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { StrukturSchema, type Struktur, type StrukturEingabe } from "@/lib/vorlagen-format";
import { stelleBenennen, StrukturSchema, type Struktur, type StrukturEingabe } from "@/lib/vorlagen-format";
/**
* Vorlagen und ihre Versionierung (#009).
@@ -376,7 +376,7 @@ export async function entwurfSpeichern(
if (!gelesen.success) {
const erster = gelesen.error.issues[0];
throw new VorlagenFehler(
erster ? `${erster.path.join(".")}: ${erster.message}` : "Der Aufbau ist unvollständig.",
erster ? `${stelleBenennen(erster.path)}${erster.message}` : "Der Aufbau ist unvollständig.",
);
}
const struktur = gelesen.data;
+178
View File
@@ -0,0 +1,178 @@
import { expect, test, type Page } from "@playwright/test";
import "dotenv/config";
/**
* Der Vorlagen-Editor durch die Oberflaeche (#010).
*
* Diese Datei gibt es, weil ein Screenshot ueber Bedienbarkeit **nichts**
* aussagt: die Seiten werden serverseitig gerendert und sehen auch dann richtig
* aus, wenn React sich nie anhaengt. Geprueft wird deshalb, was nur mit
* angehaengtem React gelingt — anlegen, tippen, sortieren, speichern — und
* anschliessend, ob es wirklich in der Datenbank steht: durch Neuladen.
*
* Voraussetzung: ein **gebauter** Stand laeuft (`pnpm build && pnpm start`) und
* `SEED_ADMIN_EMAIL`/`SEED_ADMIN_PASSWORD` sind gesetzt. Gegen `pnpm dev`
* schlaegt das hier fehl, und das ist richtig so — siehe CLAUDE.md,
* „Oberflaeche pruefen".
*
* Der Ablauf legt seine eigene Vorlage an und **archiviert** sie am Ende. Nicht
* loescht: eine veroeffentlichte Version laesst sich nicht mehr entfernen, auch
* nicht ueber einen Kaskadenloeschvorgang — der Trigger aus #009 verweigert das
* ausdruecklich, und genau das ist die Zusage, an der das ganze Archiv haengt.
* Wer diese Tests oft laufen laesst, braucht deshalb eine Wegwerf-Datenbank
* (kommt mit #018).
*/
const NAME = `ZZ E2E Editor ${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"]')]);
}
/** Legt die Vorlage an und fuehrt in ihren Entwurf. Gibt die Adresse des Editors zurueck. */
async function entwurfAnlegen(seite: Page, name: string): Promise<string> {
await seite.goto("/vorlagen");
await seite.getByLabel("Name").fill(name);
await seite.getByRole("button", { name: "Vorlage anlegen" }).click();
await expect(seite.getByText(`„${name}“ wurde angelegt.`)).toBeVisible();
await seite.goto("/vorlagen");
await seite.getByRole("link", { name }).click();
await seite.getByRole("button", { name: /bearbeiten/i }).click();
await seite.waitForURL(/\/vorlagen\/version\//);
return seite.url();
}
async function aufraeumen(seite: Page, name: string) {
await seite.goto("/vorlagen");
const verweis = seite.getByRole("link", { name });
if ((await verweis.count()) === 0) return;
await verweis.click();
await seite.getByRole("button", { name: "archivieren" }).click();
await expect(seite.getByRole("button", { name: "wieder freigeben" })).toBeVisible();
}
test.describe("Vorlagen-Editor", () => {
test("legt an, baut auf, speichert und veröffentlicht", async ({ page }) => {
test.slow();
const name = `${NAME} A`;
await anmelden(page);
await entwurfAnlegen(page, name);
// Ein Abschnitt mit einem Befehlsschritt.
await page.getByRole("button", { name: "Abschnitt hinzufügen" }).click();
await page.getByLabel("Titel des Abschnitts").fill("Vorbereitung");
const leiste = page.locator("form.sticky");
await expect(leiste).toContainText("Nicht gespeicherte Änderungen");
await page.getByRole("button", { name: "Schritt hinzufügen" }).click();
await page.getByLabel("Titel", { exact: true }).last().fill("Rechner umbenennen");
await leiste.getByRole("button", { name: "Speichern" }).click();
await expect(leiste).toContainText("Entwurf gespeichert.");
await expect(leiste).toContainText("Alles gespeichert");
// Der Beweis, dass es nicht nur im Browser steht: neu laden.
await page.reload();
await expect(page.getByLabel("Titel des Abschnitts")).toHaveValue("Vorbereitung");
await expect(page.locator("form.sticky")).toContainText("Alles gespeichert");
// Veröffentlichen macht die Version unveränderlich.
await page.getByLabel("Änderungsnotiz (optional)").fill("Erste Fassung");
await page.getByRole("button", { name: "Veröffentlichen", exact: true }).click();
/*
* Die Erfolgsmeldung steht im Veroeffentlichen-Formular – und genau das ist
* nach dem `router.refresh()` verschwunden. Geprueft wird deshalb der
* Zustand danach, nicht die Meldung: das ist ohnehin die haerteren Zusage.
*/
await expect(page.locator("form.sticky")).toHaveCount(0);
await expect(page.getByRole("button", { name: "Verschieben" })).toHaveCount(0);
await expect(page.getByText(/ist veröffentlicht und wird nicht mehr verändert/)).toBeVisible();
await aufraeumen(page, name);
});
test("warnt vor einem unbekannten Platzhalter, ohne zu blockieren", async ({ page }) => {
test.slow();
const name = `${NAME} B`;
await anmelden(page);
await entwurfAnlegen(page, name);
await page.getByRole("button", { name: "Abschnitt hinzufügen" }).click();
await page.getByLabel("Titel des Abschnitts").fill("Installation");
await page.getByRole("button", { name: "Schritt hinzufügen" }).click();
await page.getByLabel("Titel", { exact: true }).last().fill("{{hostnmae}} setzen");
await expect(page.getByText(/gehört zu keiner Variablen/)).toBeVisible();
await expect(page.getByText("{{hostnmae}}", { exact: true })).toBeVisible();
// Die Warnung ist eine Warnung: Speichern bleibt möglich.
const leiste = page.locator("form.sticky");
await leiste.getByRole("button", { name: "Speichern" }).click();
await expect(leiste).toContainText("Entwurf gespeichert.");
// Ein eingebauter Platzhalter loest keine Warnung aus.
await page.getByLabel("Titel", { exact: true }).last().fill("{{hostname}} setzen");
await expect(page.getByText(/gehört zu keiner Variablen/)).toHaveCount(0);
await aufraeumen(page, name);
});
test("sortiert am Handy mit dem Finger", async ({ page, isMobile, viewport }) => {
test.skip(!isMobile, "Nur im Projekt „handy“ – am Schreibtisch gibt es keinen Finger.");
test.slow();
const name = `${NAME} C`;
await anmelden(page);
await entwurfAnlegen(page, name);
for (const titel of ["Erster", "Zweiter"]) {
await page.getByRole("button", { name: "Abschnitt hinzufügen" }).click();
await page.getByLabel("Titel des Abschnitts").last().fill(titel);
}
const werte = async () =>
page
.getByLabel("Titel des Abschnitts")
.evaluateAll((felder) => felder.map((feld) => (feld as HTMLInputElement).value));
expect(await werte()).toEqual(["Erster", "Zweiter"]);
// Der Anfasser ist mindestens 44 px hoch – sonst trifft ihn niemand.
const anfasser = page.getByRole("button", { name: "Verschieben" }).first();
const kasten = await anfasser.boundingBox();
expect(kasten?.height ?? 0).toBeGreaterThanOrEqual(44);
expect(kasten?.width ?? 0).toBeGreaterThanOrEqual(44);
// Halten, ziehen, loslassen. Der TouchSensor wartet bewusst 200 ms, damit
// eine Wischgeste ein Bildlauf bleibt und kein Verschieben.
const x = kasten!.x + kasten!.width / 2;
const y = kasten!.y + kasten!.height / 2;
const sitzung = await page.context().newCDPSession(page);
await sitzung.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] });
await page.waitForTimeout(350);
for (let schritt = 1; schritt <= 12; schritt += 1) {
await sitzung.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [{ x, y: y + schritt * 25 }],
});
await page.waitForTimeout(40);
}
await sitzung.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
await expect.poll(werte).toEqual(["Zweiter", "Erster"]);
// Und dabei wird nicht horizontal gescrollt.
const masse = await page.evaluate(() => ({
scroll: document.documentElement.scrollWidth,
sichtbar: document.documentElement.clientWidth,
}));
expect(masse.scroll).toBeLessThanOrEqual(masse.sichtbar);
expect(viewport?.width).toBeLessThanOrEqual(430);
await aufraeumen(page, name);
});
});
+256
View File
@@ -0,0 +1,256 @@
import { describe, expect, it } from "vitest";
import {
BEISPIEL_PASSWORT,
SCHLUESSEL_REGEL,
abschnittNummer,
alsEditorAufbau,
alsStruktur,
beispielwerte,
doppelteSchluessel,
konfigFuerTyp,
mitBeispielwerten,
neueKennung,
schluesselFehler,
titelOhneNummer,
umsortieren,
verwendetePlatzhalter,
type EditorAufbau,
type EditorVariable,
} from "@/lib/editor-aufbau";
import { EINGEBAUTE_PLATZHALTER } from "@/lib/platzhalter";
import { StrukturSchema } from "@/lib/vorlagen-format";
/** Die reine Logik des Vorlagen-Editors (#010). Fuer Komponenten gibt es keine DOM-Umgebung. */
function variable(teil: Partial<EditorVariable> = {}): EditorVariable {
return {
kennung: neueKennung("variable"),
schluessel: "vorname",
beschriftung: "Vorname",
typ: "TEXT",
optionen: [],
pflicht: false,
...teil,
};
}
describe("umsortieren", () => {
it("schiebt einen Eintrag nach unten und laesst die Vorlage unberuehrt", () => {
const liste = ["a", "b", "c"];
expect(umsortieren(liste, 0, 2)).toEqual(["b", "c", "a"]);
expect(liste).toEqual(["a", "b", "c"]);
});
it("schiebt einen Eintrag nach oben", () => {
expect(umsortieren(["a", "b", "c"], 2, 1)).toEqual(["a", "c", "b"]);
});
it("gibt bei gleicher Position und ausserhalb der Liste dieselbe Liste zurueck", () => {
const liste = ["a", "b"];
expect(umsortieren(liste, 1, 1)).toBe(liste);
expect(umsortieren(liste, 0, 2)).toBe(liste);
expect(umsortieren(liste, -1, 0)).toBe(liste);
});
});
describe("Abschnittsnummer", () => {
it("nummeriert zweistellig ab eins", () => {
expect(abschnittNummer(0)).toBe("01");
expect(abschnittNummer(9)).toBe("10");
});
it("entfernt eine Nummer, die mit Mittelpunkt im Titel steht", () => {
expect(titelOhneNummer("01 · Vorbereitung")).toBe("Vorbereitung");
expect(titelOhneNummer("2·Konto")).toBe("Konto");
});
it("laesst eine Servicezeit in Ruhe — „24 · 7“ ist keine Nummerierung", () => {
// Gefunden im Review: die Regel griff auch, wenn hinter dem Mittelpunkt eine
// Ziffer stand. Aus „24 · 7 Bereitschaft“ wurde „7 Bereitschaft“, und beim
// naechsten Speichern stand das dauerhaft in der Datenbank.
expect(titelOhneNummer("24 · 7 Bereitschaft")).toBe("24 · 7 Bereitschaft");
expect(titelOhneNummer("08 · 17 Servicezeit")).toBe("08 · 17 Servicezeit");
});
it("laesst jeden anderen Trenner in Ruhe — „2. Etage“ ist eine Etage", () => {
expect(titelOhneNummer("2. Etage prüfen")).toBe("2. Etage prüfen");
expect(titelOhneNummer("02) Konto")).toBe("02) Konto");
expect(titelOhneNummer("3 - Abschluss")).toBe("3 - Abschluss");
expect(titelOhneNummer("10 Jahre Aufbewahrung")).toBe("10 Jahre Aufbewahrung");
expect(titelOhneNummer("Vorbereitung")).toBe("Vorbereitung");
});
});
describe("konfig beim Typwechsel", () => {
it("laesst fallen, was dem alten Typ gehoert", () => {
const konfig = konfigFuerTyp({ shell: "bash", befehl: "echo {{hostname}}" }, "HAKEN");
expect(konfig).toEqual({});
});
it("behaelt, was der neue Typ braucht", () => {
expect(konfigFuerTyp({ optionen: ["a", "b"], min: 1 }, "AUSWAHL")).toEqual({
optionen: ["a", "b"],
});
expect(konfigFuerTyp({ min: 1, max: 9, einheit: "GB", mehrzeilig: true }, "ZAHL")).toEqual({
min: 1,
max: 9,
einheit: "GB",
});
});
it("behaelt unbekannte Schluessel, damit ein Import aus einer spaeteren Fassung nichts verliert", () => {
expect(konfigFuerTyp({ befehl: "echo", aktionsname: "ad-anlegen" }, "HAKEN")).toEqual({
aktionsname: "ad-anlegen",
});
});
});
describe("Zustand und Struktur", () => {
const struktur = StrukturSchema.parse({
variablen: [
{ schluessel: "vorname", beschriftung: "Vorname", pflicht: true },
{ schluessel: "raum", beschriftung: "Raum", typ: "AUSWAHL", optionen: ["EG", "OG"] },
],
abschnitte: [
{
titel: "01 · Vorbereitung",
schritte: [
{ titel: "Gerät auspacken", pflicht: true },
{
titel: "Konto anlegen",
typ: "BEFEHL",
konfig: { shell: "powershell", befehl: "New-ADUser {{vorname}}" },
dokuUrl: "https://intern.example/ad",
},
],
},
],
});
it("uebernimmt die Struktur und gibt sie unveraendert zurueck — ohne die Nummer im Titel", () => {
const aufbau = alsEditorAufbau(struktur);
expect(aufbau.abschnitte[0]?.titel).toBe("Vorbereitung");
expect(aufbau.abschnitte[0]?.schritte).toHaveLength(2);
const zurueck = alsStruktur(aufbau);
expect(zurueck.abschnitte[0]?.titel).toBe("Vorbereitung");
expect(zurueck.abschnitte[0]?.schritte[1]?.konfig).toEqual({
shell: "powershell",
befehl: "New-ADUser {{vorname}}",
});
// Das Schema muss den Rueckweg annehmen, sonst scheitert erst das Speichern.
expect(StrukturSchema.safeParse(zurueck).success).toBe(true);
});
it("laesst leere Textfelder weg, statt sie als leere Zeichenkette zu schicken", () => {
const aufbau: EditorAufbau = {
variablen: [],
abschnitte: [
{
kennung: "a",
titel: "Vorbereitung",
schritte: [
{
kennung: "s",
titel: "Gerät auspacken",
hilfetext: " ",
typ: "HAKEN",
pflicht: false,
konfig: {},
dokuUrl: " https://intern.example/x ",
},
],
},
],
};
const schritt = alsStruktur(aufbau).abschnitte[0]?.schritte[0];
expect(schritt?.hilfetext).toBeUndefined();
expect(schritt?.dokuUrl).toBe("https://intern.example/x");
});
it("schickt Optionen nur fuer eine Auswahl-Variable mit", () => {
const aufbau: EditorAufbau = {
variablen: [variable({ typ: "TEXT", optionen: ["alt", "vergessen"] })],
abschnitte: [],
};
expect(alsStruktur(aufbau).variablen[0]?.optionen).toEqual([]);
});
it("findet die verwendeten Platzhalter", () => {
expect([...verwendetePlatzhalter(alsEditorAufbau(struktur))]).toEqual(["vorname"]);
});
});
describe("Variablenschluessel", () => {
it("nimmt gueltige Schluessel an", () => {
for (const schluessel of ["vorname", "ad_gruppe", "raum2"]) {
expect(schluesselFehler(schluessel), schluessel).toBeUndefined();
}
});
it("lehnt ab, was der Platzhalter nicht wiedererkennen koennte", () => {
for (const schluessel of ["Vorname", "mit-strich", "2te_wahl", ""]) {
expect(schluesselFehler(schluessel), schluessel).toBeDefined();
}
});
it("nennt die Regel als Satz, damit der Hinweis am Feld stehen kann", () => {
expect(SCHLUESSEL_REGEL).toContain("Kleinbuchstaben");
});
it("findet doppelte Schluessel und ignoriert leere", () => {
expect(
doppelteSchluessel([
variable({ schluessel: "vorname" }),
variable({ schluessel: "vorname" }),
variable({ schluessel: "" }),
variable({ schluessel: "" }),
]),
).toEqual(["vorname"]);
});
});
describe("Beispielwerte der Vorschau", () => {
const werte = beispielwerte([], "24.09.2026");
it("hat fuer jeden eingebauten Platzhalter einen Wert", () => {
for (const platzhalter of EINGEBAUTE_PLATZHALTER) {
expect(werte[platzhalter.schluessel], platzhalter.schluessel).toBeTruthy();
}
});
it("erzeugt fuer das generierte Passwort nie ein echtes, sondern immer denselben Beispieltext", () => {
expect(werte["generiertes_passwort"]).toBe(BEISPIEL_PASSWORT);
expect(beispielwerte([], "24.09.2026")["generiertes_passwort"]).toBe(BEISPIEL_PASSWORT);
});
it("nimmt das Datum von aussen, damit Server und Browser dasselbe rendern", () => {
expect(werte["datum"]).toBe("24.09.2026");
});
it("zieht fuer einen Schluessel, den es als Variable gibt, dessen Beispielwert vor", () => {
const eigene = beispielwerte(
[variable({ schluessel: "abteilung", typ: "AUSWAHL", optionen: ["IT", "Einkauf"] })],
"24.09.2026",
);
expect(eigene["abteilung"]).toBe("IT");
});
it("ersetzt Platzhalter auch mit Leerzeichen in den Klammern", () => {
expect(mitBeispielwerten("Hallo {{ vorname }} ({{sam}})", werte)).toBe("Hallo Anna (a.beispiel)");
});
it("laesst einen unbekannten Platzhalter stehen, so wie es der Lauf auch tut", () => {
expect(mitBeispielwerten("{{gibtsnicht}}", werte)).toBe("{{gibtsnicht}}");
});
it("setzt einen Wert mit $& woertlich ein", () => {
// Gefunden im Review: `String.replace` deutet im Ersatztext `$&`, `` $` ``,
// `$'` und `$1` als Rueckverweise. Ein Auswahlwert „Kosten $& Nutzen“ setzte
// den gefundenen Platzhalter wieder ein. In #012 baut derselbe Weg einen
// PowerShell-Befehl zusammen — dort waere es ein verfaelschter Befehl.
for (const wert of ["Kosten $& Nutzen", "vor $` danach", "A $' B", "Gruppe $1"]) {
expect(mitBeispielwerten("Raum: {{raum}}", { raum: wert }), wert).toBe(`Raum: ${wert}`);
}
});
});
+86
View File
@@ -0,0 +1,86 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { Markdown } from "@/components/markdown";
/**
* Hilfetexte als Markdown – und nichts darueber hinaus (#010).
*
* Ein Hilfetext wird von einem Redakteur geschrieben und von jedem Techniker
* geoeffnet. Rohes HTML darin waere fremder Code in fremden Browsern; ueber den
* JSON-Import kaeme er sogar von ausserhalb des Hauses. `react-markdown` laesst
* HTML von sich aus fallen — dieser Test haelt fest, dass niemand spaeter
* `rehype-raw` dazustellt, weil „der Kunde Tabellen in HTML schreibt".
*
* Gerendert wird mit `react-dom/server`, damit der Test ohne DOM-Umgebung laeuft.
*/
function alsHtml(text: string): string {
return renderToStaticMarkup(<Markdown text={text} />);
}
describe("Markdown im Hilfetext", () => {
it("rendert Fettschrift, Listen und Code", () => {
const html = alsHtml("**wichtig**\n\n- eins\n- zwei\n\n`Get-Help`");
expect(html).toContain("<strong>wichtig</strong>");
expect(html).toContain("<li>eins</li>");
expect(html).toContain("Get-Help");
});
it("rendert Tabellen aus GitHub-Markdown", () => {
const html = alsHtml("| Gerät | Port |\n|---|---|\n| Switch | 24 |");
expect(html).toContain("<table>");
expect(html).toContain("Switch");
});
it("laesst ein script-Element nicht durch", () => {
const html = alsHtml('Hinweis <script>alert("1")</script> Ende');
expect(html).not.toContain("<script>");
// Der Text bleibt sichtbar – maskiert, nicht heimlich verschluckt.
expect(html).toContain("&lt;script&gt;");
});
it("laesst weder Bilder mit Ereignisattribut noch eingebettete Rahmen durch", () => {
// Geprueft wird die *Auszeichnung*, nicht das Wort: „onerror" steht danach
// noch im Text, aber maskiert – es wird angezeigt und nicht ausgefuehrt.
const html = alsHtml('<img src=x onerror="alert(1)"> <iframe src="https://fremd.example"></iframe>');
expect(html).not.toContain("<img");
expect(html).not.toContain("<iframe");
expect(html).toContain("&lt;img");
});
it("oeffnet Links in einem neuen Tab ohne Herkunft", () => {
// Ein Klick mitten im Lauf darf den Lauf nicht verlassen, und die Zielseite
// muss nicht erfahren, aus welcher Installation der Klick kam.
const html = alsHtml("[Doku](https://intern.example/doku)");
expect(html).toContain('target="_blank"');
expect(html).toContain('rel="noreferrer"');
});
it("macht aus einem javascript-Link keinen klickbaren Verweis", () => {
// Markdown erlaubt `[Text](javascript:…)`. react-markdown filtert das
// Protokoll; faellt dieser Schutz weg, faellt es hier auf.
const html = alsHtml("[klick mich](javascript:alert(1))");
expect(html).not.toContain("javascript:alert");
});
it("gibt bei leerem Text nichts aus, ohne zu scheitern", () => {
expect(() => alsHtml("")).not.toThrow();
});
});
describe("Verweise und Bilder", () => {
it("macht aus einem Verweis ohne erlaubtes Protokoll keinen Verweis", () => {
// react-markdown leert den href. Ein <a href=""> laedt beim Klick die
// aktuelle Seite in einem neuen Tab – verwirrender als gar kein Verweis.
const html = renderToStaticMarkup(<Markdown text="[klick mich](javascript:alert(1))" />);
expect(html).not.toContain("<a");
expect(html).toContain("klick mich");
});
it("zeigt ein Bild als Alternativtext, statt es einzubinden", () => {
// Ein Bild laege auf einem fremden Host, und img-src erlaubt nur self.
const html = renderToStaticMarkup(<Markdown text="![Schrankplan](https://fremd.example/p.png)" />);
expect(html).not.toContain("<img");
expect(html).toContain("[Bild: Schrankplan]");
});
});
+15 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { navigationFuer } from "@/lib/navigation";
import { existsSync } from "node:fs";
import { NAVIGATION, navigationFuer } from "@/lib/navigation";
import { darf } from "@/server/auth/rollen";
import type { Rolle } from "@/generated/prisma/enums";
@@ -55,6 +56,19 @@ describe("Aufbau der Eintraege", () => {
}
});
it("hat zu jedem verfuegbaren Eintrag auch eine Seite", () => {
// Das Umstellen von `verfuegbar` ist ein Handgriff im Meilenstein, der die
// Seite liefert. Wird es zu frueh gemacht, zeigt die Navigation auf eine
// 404 – und genau das faellt beim Klicken selten auf, weil man die eigene
// neue Seite kennt und die anderen nicht anfasst.
for (const eintrag of NAVIGATION) {
if (!eintrag.verfuegbar) continue;
const teil = eintrag.pfad === "/" ? "" : eintrag.pfad;
const seite = new URL(`../src/app/(app)${teil}/page.tsx`, import.meta.url);
expect(existsSync(seite), `${eintrag.pfad} ist verfuegbar, hat aber keine page.tsx`).toBe(true);
}
});
it("hat keine doppelten Pfade je Modus", () => {
for (const modus of ["INTERN", "DIENSTLEISTUNG"] as const) {
const pfade = pfadeFuer(modus, "ADMIN");
+150
View File
@@ -0,0 +1,150 @@
import { describe, expect, it } from "vitest";
import {
EINGEBAUTE_PLATZHALTER,
QUELLEN_TEXT,
istEingebaut,
unbekanntePlatzhalter,
unbenutzteVariablen,
} from "@/lib/platzhalter";
import { platzhalterDerStruktur, VariableSchema, type Struktur } from "@/lib/vorlagen-format";
/**
* Die eingebauten Platzhalter (#010).
*
* Die Liste ist Daten, und Daten koennen still falsch sein: ein Schluessel mit
* Bindestrich wuerde im Lauf nie ersetzt, weil `platzhalterFinden` ihn nicht als
* Platzhalter erkennt. Deshalb wird die Liste hier gegen genau dieselbe Regel
* geprueft, die auch fuer Variablen gilt.
*/
describe("Liste der eingebauten Platzhalter", () => {
it("haelt sich an die Schluesselregel der Variablen", () => {
const schluesselRegel = VariableSchema.shape.schluessel;
for (const platzhalter of EINGEBAUTE_PLATZHALTER) {
const ergebnis = schluesselRegel.safeParse(platzhalter.schluessel);
expect(ergebnis.success, `${platzhalter.schluessel}: ${ergebnis.error?.message}`).toBe(true);
}
});
it("nennt jeden Schluessel nur einmal", () => {
const schluessel = EINGEBAUTE_PLATZHALTER.map((p) => p.schluessel);
expect(new Set(schluessel).size).toBe(schluessel.length);
});
it("erklaert jeden Platzhalter und ordnet ihn einer benannten Quelle zu", () => {
for (const platzhalter of EINGEBAUTE_PLATZHALTER) {
expect(platzhalter.erklaerung.trim().length, platzhalter.schluessel).toBeGreaterThan(0);
expect(QUELLEN_TEXT[platzhalter.quelle], platzhalter.schluessel).toBeTruthy();
}
});
it("enthaelt die im Auftrag genannten Platzhalter", () => {
// Namentlich, damit das Verschwinden eines einzelnen auffaellt – eine
// Vorlage, die ihn benutzt, bekaeme sonst plötzlich eine Warnung.
for (const schluessel of [
"hostname",
"seriennummer",
"inventarnummer",
"ticket",
"datum",
"techniker",
"kunde",
"kundenkuerzel",
"mitarbeiter",
"benutzer",
"vorname",
"nachname",
"upn",
"sam",
"mail",
"abteilung",
"titel",
"vorgesetzter",
"vorgesetzter_sam",
"dn",
"ou",
"gruppen",
"generiertes_passwort",
]) {
expect(istEingebaut(schluessel), schluessel).toBe(true);
}
});
it("kennzeichnet die Platzhalter, die nur im Modus Dienstleistung gefuellt sind", () => {
const nurDienstleistung = EINGEBAUTE_PLATZHALTER.filter((p) => p.nurDienstleistung).map(
(p) => p.schluessel,
);
expect(nurDienstleistung.sort()).toEqual(["kunde", "kundenkuerzel"]);
});
});
describe("Unbekannte Platzhalter", () => {
it("meldet, was weder eingebaut noch als Variable definiert ist", () => {
expect(unbekanntePlatzhalter(["hostname", "os_version", "tipfehler"], ["os_version"])).toEqual([
"tipfehler",
]);
});
it("meldet nichts, wenn alles bekannt ist", () => {
expect(unbekanntePlatzhalter(["hostname", "generiertes_passwort"], [])).toEqual([]);
});
it("findet einen Tippfehler in einem echten Aufbau", () => {
// Der praktische Fall: „{{hostnmae}}" im Befehl. Ohne Warnung merkt es erst
// der Techniker, wenn der Befehl den Platzhalter im Klartext enthaelt.
const struktur: Struktur = {
variablen: [
{
schluessel: "os_version",
beschriftung: "Betriebssystem",
typ: "TEXT",
optionen: [],
pflicht: false,
},
],
abschnitte: [
{
titel: "Installation",
schritte: [
{
titel: "Rechner umbenennen",
typ: "BEFEHL",
pflicht: true,
konfig: {
shell: "powershell",
befehl: 'Rename-Computer -NewName "{{hostnmae}}" # soll {{hostname}} sein',
},
},
{
titel: "{{os_version}} installieren",
hilfetext: "Danach {{ datum }} im Ticket vermerken.",
typ: "HAKEN",
pflicht: true,
konfig: {},
},
],
},
],
};
const verwendet = platzhalterDerStruktur(struktur);
expect(verwendet).toContain("hostnmae");
expect(
unbekanntePlatzhalter(
verwendet,
struktur.variablen.map((v) => v.schluessel),
),
).toEqual(["hostnmae"]);
});
});
describe("Unbenutzte Variablen", () => {
it("meldet eine Variable, die nirgends vorkommt", () => {
// Eine definierte Variable, die kein Schritt benutzt, fragt den Techniker
// beim Start nach etwas, das nie irgendwo auftaucht.
expect(unbenutzteVariablen(["hostname"], ["os_version", "join_typ"])).toEqual(["os_version", "join_typ"]);
});
it("meldet nichts, wenn jede Variable benutzt wird", () => {
expect(unbenutzteVariablen(["os_version"], ["os_version"])).toEqual([]);
});
});
+160
View File
@@ -0,0 +1,160 @@
import { describe, expect, it } from "vitest";
import { NextRequest } from "next/server";
import { istOeffentlich, proxy, sicherheitsrichtlinie } from "@/proxy";
/**
* Die Content-Security-Policy (#005, korrigiert in #010).
*
* Sie war in beiden Betriebsarten gleich streng — und genau deshalb hydrierte
* React im Entwicklungsmodus **nie**: `script-src` ohne `unsafe-eval` verbietet
* das, was React und Turbopack dort tun. Die Seiten sahen richtig aus, aber kein
* Knopf tat etwas, und ein Screenshot zeigte davon nichts. Gefunden erst, als der
* Editor aus #010 im Browser bedient werden sollte.
*
* Diese Tests halten beide Seiten fest: dass die Lockerung im Entwicklungsmodus
* da ist, und dass sie im ausgelieferten Stand fehlt.
*/
const NONCE = "abc123";
function teile(richtlinie: string): Map<string, string> {
return new Map(
richtlinie.split("; ").map((eintrag) => {
const [name, ...rest] = eintrag.split(" ");
return [name ?? "", rest.join(" ")];
}),
);
}
describe("Ausgelieferter Stand", () => {
const richtlinie = sicherheitsrichtlinie(NONCE, false);
it("erlaubt kein unsafe-eval", () => {
expect(richtlinie).not.toContain("unsafe-eval");
});
it("erlaubt keine WebSocket-Verbindungen", () => {
expect(teile(richtlinie).get("connect-src")).toBe("'self'");
});
it("bindet Skripte an die Nonce dieser Anfrage", () => {
expect(teile(richtlinie).get("script-src")).toBe(`'self' 'nonce-${NONCE}' 'strict-dynamic'`);
});
it("verbietet das Einbetten in fremde Seiten und Objekte", () => {
const werte = teile(richtlinie);
expect(werte.get("frame-ancestors")).toBe("'none'");
expect(werte.get("object-src")).toBe("'none'");
expect(werte.get("base-uri")).toBe("'self'");
expect(werte.get("form-action")).toBe("'self'");
});
it("erlaubt Bilder als data: und blob: – QR-Codes und Kamerabilder", () => {
expect(teile(richtlinie).get("img-src")).toBe("'self' data: blob:");
});
});
describe("Entwicklungsmodus", () => {
const richtlinie = sicherheitsrichtlinie(NONCE, true);
it("erlaubt unsafe-eval, sonst hydriert React nicht", () => {
expect(teile(richtlinie).get("script-src")).toContain("'unsafe-eval'");
});
it("erlaubt die WebSocket-Verbindung von Fast Refresh", () => {
expect(teile(richtlinie).get("connect-src")).toBe("'self' ws: wss:");
});
it("lockert sonst nichts", () => {
// Namentlich verglichen: was hier gleich bleiben soll, bleibt gleich.
const streng = teile(sicherheitsrichtlinie(NONCE, false));
const locker = teile(richtlinie);
for (const name of [...streng.keys()]) {
if (name === "script-src" || name === "connect-src") continue;
expect(locker.get(name), name).toBe(streng.get(name));
}
expect([...locker.keys()]).toEqual([...streng.keys()]);
});
});
describe("Oeffentliche Pfade", () => {
it("laesst Anmeldung, Auth-Rueckruf und Healthcheck ohne Cookie durch", () => {
for (const pfad of [
"/anmelden",
"/api/auth/oidc/start",
"/api/gesundheit",
"/manifest.webmanifest",
"/icon.svg",
]) {
expect(istOeffentlich(pfad), pfad).toBe(true);
}
});
it("laesst nichts anderes durch", () => {
for (const pfad of ["/", "/vorlagen", "/admin", "/api/laeufe/1/zustand", "/anmeldenX"]) {
expect(istOeffentlich(pfad), pfad).toBe(false);
}
});
});
describe("Die Entscheidung im Proxy selbst", () => {
/**
* Die beiden Tests oben pruefen die Funktion mit einem Argument, das der Test
* setzt — genau das hat der Review beanstandet: die eigentliche Entscheidung
* `process.env.NODE_ENV !== "production"` blieb ungeprueft. Wer dort `true`
* einsetzt, haette gruene Tests und lieferte `unsafe-eval` aus.
*
* Hier laeuft deshalb `proxy()` selbst, mit gesetztem NODE_ENV.
*/
function anfrageMitCookie(): NextRequest {
const anfrage = new NextRequest(new URL("http://127.0.0.1:3000/vorlagen"));
anfrage.cookies.set("lz_sitzung", "egal");
return anfrage;
}
function mitNodeEnv<T>(wert: string, was: () => T): T {
const vorher = process.env.NODE_ENV;
// NODE_ENV ist in den Typen schreibgeschuetzt, zur Laufzeit ist es eine
// gewoehnliche Variable – und genau die liest der Proxy.
const umgebung = process.env as Record<string, string | undefined>;
umgebung["NODE_ENV"] = wert;
try {
return was();
} finally {
umgebung["NODE_ENV"] = vorher;
}
}
it("liefert im gebauten Stand keine Lockerung aus", () => {
const richtlinie = mitNodeEnv("production", () =>
proxy(anfrageMitCookie()).headers.get("Content-Security-Policy"),
);
expect(richtlinie).toBeTruthy();
expect(richtlinie).not.toContain("unsafe-eval");
expect(richtlinie).toContain("connect-src 'self';");
expect(richtlinie).toContain("'strict-dynamic'");
});
it("lockert im Entwicklungsmodus", () => {
const richtlinie = mitNodeEnv("development", () =>
proxy(anfrageMitCookie()).headers.get("Content-Security-Policy"),
);
expect(richtlinie).toContain("'unsafe-eval'");
expect(richtlinie).toContain("ws: wss:");
});
it("gibt jeder Antwort eine eigene Nonce", () => {
// Eine wiederverwendete Nonce waere so gut wie keine.
const erste = proxy(anfrageMitCookie()).headers.get("Content-Security-Policy");
const zweite = proxy(anfrageMitCookie()).headers.get("Content-Security-Policy");
expect(erste).not.toBe(zweite);
});
it("schickt ohne Sitzungscookie auf die Anmeldeseite, mit dem Ziel im Gepäck", () => {
const antwort = proxy(new NextRequest(new URL("http://127.0.0.1:3000/vorlagen?suche=pc")));
expect(antwort.status).toBe(307);
const ziel = new URL(antwort.headers.get("location") ?? "");
expect(ziel.pathname).toBe("/anmelden");
expect(ziel.searchParams.get("ziel")).toBe("/vorlagen?suche=pc");
});
});
+27 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { z } from "@/lib/validierung";
import { AustauschSchema, StrukturSchema } from "@/lib/vorlagen-format";
import { AustauschSchema, StrukturSchema, stelleBenennen } from "@/lib/vorlagen-format";
/**
* Zod ist in src/lib/validierung.ts auf Deutsch eingestellt (#009).
@@ -32,3 +32,29 @@ describe("Deutsche Meldungen", () => {
expect(ergebnis.error?.issues[0]?.message).toMatch(/Unbekanntes Format/);
});
});
describe("Stelle benennen", () => {
it("macht aus einem Zod-Pfad eine Stelle im Editor", () => {
expect(stelleBenennen(["struktur", "abschnitte", 2, "schritte", 0, "titel"])).toBe(
"Abschnitt 03, Schritt 1: ",
);
expect(stelleBenennen(["variablen", 1, "schluessel"])).toBe("Variable 2: ");
expect(stelleBenennen(["abschnitte", 0, "schritte", 3, "konfig", "optionen", 1])).toBe(
"Abschnitt 01, Schritt 4, Option 2: ",
);
});
it("liefert nichts, wenn der Pfad keine Stelle beschreibt", () => {
// Dann bleibt die Meldung des Schemas fuer sich allein stehen – besser als
// ein leerer Doppelpunkt davor.
expect(stelleBenennen(["name"])).toBe("");
expect(stelleBenennen([])).toBe("");
});
it("nennt die Stelle in der Reihenfolge, in der man sie sucht", () => {
// Erst der Abschnitt, dann der Schritt darin – nicht umgekehrt.
const stelle = stelleBenennen(["abschnitte", 9, "schritte", 11, "titel"]);
expect(stelle.indexOf("Abschnitt")).toBeLessThan(stelle.indexOf("Schritt"));
expect(stelle).toBe("Abschnitt 10, Schritt 12: ");
});
});
+39
View File
@@ -3,6 +3,7 @@ import { Betriebsmodus } from "@/generated/prisma/enums";
import {
versionMitAufbau,
versionsHistorie,
vorlageHolen,
vorlagenFilterwerte,
vorlagenListe,
} from "@/server/queries/vorlagen";
@@ -153,4 +154,42 @@ describe.runIf(datenbankVerfuegbar)("Mandantensicht auf Vorlagen", () => {
expect(await vorlagenListe(techniker)).toHaveLength(3);
});
});
describe("Einzelne Vorlage", () => {
it("gibt eine fremde Vorlage nicht heraus", async () => {
// Die Vorlagenseite haengt allein an dieser Funktion. Gaebe sie die Zeile
// heraus, staenden Name, Beschreibung und Versionsgeschichte offen – die
// Kennung steht in jedem Link, den jemand weitergibt.
const { techniker, fremde, meine, globale } = await aufbau();
expect(await vorlageHolen(fremde.vorlage.id, techniker)).toBeUndefined();
expect(await vorlageHolen(meine.vorlage.id, techniker)).toBeDefined();
expect(await vorlageHolen(globale.vorlage.id, techniker)).toBeDefined();
});
it("gibt einem Administrator auch eine fremde heraus", async () => {
const { admin, fremde } = await aufbau();
expect(await vorlageHolen(fremde.vorlage.id, admin)).toBeDefined();
});
it("liefert dieselben Felder wie die Liste", async () => {
// Zwei Lesemodelle fuer dieselbe Zeile laufen auseinander; deshalb bauen
// beide aus derselben Auswahl. Der Test haelt das fest.
const { techniker, meine } = await aufbau();
const ausListe = (await vorlagenListe(techniker, { auchArchivierte: true })).find(
(zeile) => zeile.id === meine.vorlage.id,
);
expect(await vorlageHolen(meine.vorlage.id, techniker)).toEqual(ausListe);
});
it("findet auch eine archivierte Vorlage", async () => {
// Die Seite muss sie zeigen koennen, sonst liesse sich nichts freigeben.
const { techniker, meine } = await aufbau();
await testPrisma.vorlage.update({
where: { id: meine.vorlage.id },
data: { archiviert: true },
});
const zeile = await vorlageHolen(meine.vorlage.id, techniker);
expect(zeile?.archiviert).toBe(true);
});
});
});
+25 -2
View File
@@ -174,7 +174,11 @@ describe.runIf(datenbankVerfuegbar)("Vorlagen-Versionierung", () => {
).rejects.toThrow(/„upn“ gibt es zweimal/);
});
it("nennt bei einem Fehler im Aufbau den Pfad", async () => {
it("nennt die Stelle so, wie sie im Editor steht", async () => {
// „abschnitte.0.schritte.1.titel“ ist richtig und nutzlos: der Redakteur
// muesste von null an zaehlen. Der Review hat es als den Weg benannt, auf
// dem Arbeit wirklich verloren geht — mehrfach vergeblich speichern, dann
// weggehen.
const benutzer = await benutzerAnlegen();
const { version } = await vorlageAnlegen({ name: "Probe" }, benutzer.id);
await expect(
@@ -182,7 +186,7 @@ describe.runIf(datenbankVerfuegbar)("Vorlagen-Versionierung", () => {
variablen: [],
abschnitte: [{ titel: "Abschnitt", schritte: [{ titel: "gut" }, { titel: " " }] }],
}),
).rejects.toThrow(/abschnitte\.0\.schritte\.1\.titel/);
).rejects.toThrow("Abschnitt 01, Schritt 2: Ein Schritt braucht einen Titel.");
});
it("hinterlässt bei einem Fehler keinen halben Entwurf", async () => {
@@ -489,6 +493,25 @@ describe.runIf(datenbankVerfuegbar)("Vorlagen-Versionierung", () => {
expect(await testPrisma.protokoll.count({ where: { aktion: "Vorlage archiviert" } })).toBe(1);
});
it("lässt die Kundenzuordnung stehen, wenn sie nicht mitgeschickt wird", async () => {
// Im Modus Intern zeigt das Formular keine Kundenwahl. Wuerde ein
// fehlendes Feld als „auf leer setzen“ gelten, verlöre jede Vorlage beim
// Speichern ihrer Kopfdaten die Zuordnung – und nach einem Wechsel zurueck
// in den Modus Dienstleistung waere sie fuer alle sichtbar.
const benutzer = await benutzerAnlegen();
const kunde = await testPrisma.kunde.create({ data: { name: "Meiner", kuerzel: "MEIN" } });
const { vorlage } = await vorlageAnlegen({ name: "Probe", kundeId: kunde.id }, benutzer.id);
await vorlageBearbeiten(vorlage.id, { name: "Anders" }, benutzer.id);
expect((await testPrisma.vorlage.findUniqueOrThrow({ where: { id: vorlage.id } })).kundeId).toBe(
kunde.id,
);
// Ausdrücklich `null` loest sie dagegen.
await vorlageBearbeiten(vorlage.id, { name: "Anders", kundeId: null }, benutzer.id);
expect((await testPrisma.vorlage.findUniqueOrThrow({ where: { id: vorlage.id } })).kundeId).toBeNull();
});
it("schreibt nichts, wenn sich nichts ändert", async () => {
const benutzer = await benutzerAnlegen();
const { vorlage } = await veroeffentlichteVorlage(benutzer.id);
+48 -1
View File
@@ -143,16 +143,31 @@ describe("Rechtepruefung an den Einstiegspunkten", () => {
it("beginnt jede Server Action mit einer Rechtepruefung", () => {
// Strukturell geprueft, weil eine vergessene Pruefung sonst erst auffiele,
// wenn jemand sie ausnutzt. Die Rechtematrix selbst deckt tests/rollen.test.ts ab.
//
// Anerkannt wird auch ein Helfer **in derselben Datei**, der seinerseits
// `rechtErforderlich` ruft: seit #010 fangen die Vorlagen-Aktionen den
// RechteFehler ab und geben eine Meldung zurueck, statt die Seite
// auszuhaengen — im Editor waere sonst jede ungespeicherte Arbeit verloren.
// Die Pruefung findet trotzdem statt, nur eine Ebene tiefer.
const befunde: string[] = [];
for (const name of readdirSync(verzeichnis).filter((n) => n.endsWith(".ts"))) {
const quelle = readFileSync(join(verzeichnis, name), "utf8");
expect(quelle.startsWith('"use server";'), `${name} fehlt "use server"`).toBe(true);
// Helfer, die selbst pruefen – Name und Rumpf aus derselben Datei.
const helfer = [...quelle.matchAll(/(?:async )?function (\w+)\(([\s\S]*?)\n\}/g)]
.filter((treffer) => treffer[2]?.includes("rechtErforderlich("))
.map((treffer) => treffer[1])
.filter((funktion): funktion is string => funktion !== undefined);
for (const treffer of quelle.matchAll(/export async function (\w+)\(([\s\S]*?)\n\}/g)) {
const [, funktion, rumpf] = treffer;
if (!funktion || OEFFENTLICHE_AKTIONEN.has(funktion)) continue;
if (!rumpf?.includes("rechtErforderlich(")) {
const prueft =
rumpf?.includes("rechtErforderlich(") === true ||
helfer.some((helfername) => rumpf?.includes(`${helfername}(`) === true);
if (!prueft) {
befunde.push(`${name}: ${funktion} prueft kein Recht`);
}
}
@@ -161,6 +176,38 @@ describe("Rechtepruefung an den Einstiegspunkten", () => {
expect(befunde).toEqual([]);
});
it("erkennt eine Aktion ohne jede Pruefung", () => {
// Der Test oben ist nachsichtiger geworden; dieser hier zeigt, dass er
// trotzdem noch anschlaegt. Geprueft wird die Regel, nicht die Datei.
const quelle = [
'"use server";',
"async function mitPruefung() {",
' await rechtErforderlich("vorlagenBearbeiten");',
"}",
"",
"export async function gut() {",
" await mitPruefung();",
"}",
"",
"export async function schlecht() {",
" return 42;",
"}",
].join("\n");
const helfer = [...quelle.matchAll(/(?:async )?function (\w+)\(([\s\S]*?)\n\}/g)]
.filter((treffer) => treffer[2]?.includes("rechtErforderlich("))
.map((treffer) => treffer[1]);
const ohnePruefung = [...quelle.matchAll(/export async function (\w+)\(([\s\S]*?)\n\}/g)]
.filter(
([, , rumpf]) =>
rumpf?.includes("rechtErforderlich(") !== true &&
!helfer.some((name) => rumpf?.includes(`${name}(`) === true),
)
.map(([, funktion]) => funktion);
expect(ohnePruefung).toEqual(["schlecht"]);
});
it("findet ueberhaupt Aktionen, sonst prueft der Test oben nichts", () => {
const aktionen = readdirSync(verzeichnis)
.filter((n) => n.endsWith(".ts"))
+3 -1
View File
@@ -6,7 +6,9 @@ export default defineConfig({
alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },
},
test: {
include: ["tests/**/*.test.ts", "src/**/*.test.ts"],
// `tsx` ist mitgemeint: Komponenten werden mit react-dom/server zu Text
// gerendert und geprueft – dafuer braucht es keine DOM-Umgebung.
include: ["tests/**/*.test.ts", "tests/**/*.test.tsx", "src/**/*.test.ts", "src/**/*.test.tsx"],
environment: "node",
globalSetup: ["tests/global-setup.ts"],
setupFiles: ["tests/setup-env.ts"],