feat(vorlagen): Editor, Liste und Versionsgeschichte #24
@@ -47,6 +47,9 @@ jobs:
|
|||||||
- name: Code-Stil prüfen
|
- name: Code-Stil prüfen
|
||||||
run: pnpm lint
|
run: pnpm lint
|
||||||
|
|
||||||
|
- name: Format prüfen
|
||||||
|
run: pnpm format:pruefen
|
||||||
|
|
||||||
- name: Typen prüfen
|
- name: Typen prüfen
|
||||||
run: pnpm typecheck
|
run: pnpm typecheck
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
- 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
|
- 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)
|
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
|
### 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)
|
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
|
- 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)
|
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)
|
||||||
|
|||||||
@@ -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
|
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
|
## Vor jedem Commit
|
||||||
|
|
||||||
```bash
|
```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
|
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
|
höchstens 72 Zeichen im Betreff. Ein Commit pro abgeschlossener, lauffähiger Änderung. Kein
|
||||||
`wip` auf `main`. Fußzeile referenziert das Issue: `Closes #12`.
|
`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
|
Komponenten greifen nur darauf zu, nie auf rohe Hexwerte. Schriften selbst gehostet über
|
||||||
`@fontsource-variable`. Icons ausschließlich Lucide-Stroke-SVGs — **keine Emojis**. Keine
|
`@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.
|
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.
|
**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 -->
|
||||||
|
|||||||
@@ -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
|
Import wäre der Ort, an dem das erst auffiele. Das Format ist in
|
||||||
[`docs/vorlagen-format.md`](vorlagen-format.md) beschrieben.
|
[`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)
|
## 5. Der Weg zu Phase 4 (Aktionen)
|
||||||
|
|
||||||
Die in den Vorlagen vorbereiteten PowerShell-Befehle sind bewusst die **Vorstufe ausführbarer
|
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
|
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.
|
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
|
## 6. Betrieb: zwei Wege, eine Regel
|
||||||
|
|
||||||
Die Datenbank ist auf beiden Wegen von außen nicht erreichbar:
|
Die Datenbank ist auf beiden Wegen von außen nicht erreichbar:
|
||||||
|
|||||||
@@ -12,11 +12,12 @@ als Unterpunkte mit Aufgabe und Dauer.
|
|||||||
| #007 | M2 – Auth, Rollen, Benutzer | 1 h 35 min |
|
| #007 | M2 – Auth, Rollen, Benutzer | 1 h 35 min |
|
||||||
| #008 | M3 – Betriebsmodus und Kunden | 1 h 20 min |
|
| #008 | M3 – Betriebsmodus und Kunden | 1 h 20 min |
|
||||||
| #009 | M4 – Vorlagen-Versionierung im Service | 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 |
|
| 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,
|
- #010 Vorlagen-Editor: Liste mit Suche und Filtern, Abschnitte und Schritte per Drag & Drop,
|
||||||
Vorschau, Veröffentlichen, Versionshistorie, Warnung bei unbekannten Platzhaltern.
|
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.
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# #010 M5 – Vorlagen-Editor
|
# #010 M5 – Vorlagen-Editor
|
||||||
|
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.1.0
|
**Meilenstein:** v0.1.0
|
||||||
**Labels:** phase-1, frontend
|
**Labels:** phase-1, frontend
|
||||||
**Geschätzt:** 10 h **Tatsächlich:** –
|
**Geschätzt:** 10 h **Tatsächlich:** 2 h 10 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
|
|
||||||
@@ -11,21 +11,68 @@ Der Editor, in dem ein Redakteur eine Vorlage baut, ohne den Kopf über Versione
|
|||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
|
|
||||||
- [ ] Vorlagenliste mit Suche, Filter nach Kategorie und Tag, Kennzeichnung „Entwurf vorhanden“
|
- [x] 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`)
|
- [x] 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
|
- [x] 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
|
- [x] 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
|
- [x] Typabhängige `konfig`-Formulare: Auswahloptionen, Min/Max/Einheit, bei `TEXT` mehrzeilig, bei `BEFEHL` die Shell (`powershell`/`bash`/`cmd`) und der Befehlstext
|
||||||
- [ ] Variablen definieren: `key`, Label, Typ, Optionen, Pflicht, Position
|
- [x] Variablen definieren: `key`, Label, Typ, Optionen, Pflicht, Position
|
||||||
- [ ] Vorschau der Vorlage wie im Lauf, mit Beispielwerten
|
- [x] Vorschau der Vorlage wie im Lauf, mit Beispielwerten
|
||||||
- [ ] Veröffentlichen mit Änderungsnotiz, Versionshistorie ansehen, Vorlage duplizieren, JSON-Export und -Import
|
- [x] Veröffentlichen mit Änderungsnotiz, Versionshistorie ansehen, Vorlage duplizieren, JSON-Export und -Import
|
||||||
- [ ] Warnung im Editor bei unbekannten Platzhaltern in Titel, Hilfetext und Befehlstext
|
- [x] Warnung im Editor bei unbekannten Platzhaltern in Titel, Hilfetext und Befehlstext
|
||||||
- [ ] Screenshots Liste, Editor, Historie in `docs/screenshots/m5/`
|
- [x] Screenshots Liste, Editor, Historie in `docs/screenshots/m5/`
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
|
|
||||||
- [ ] Eine Vorlage lässt sich vollständig ohne Umweg über die Datenbank anlegen, sortieren, veröffentlichen
|
- [x] 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
|
- [x] 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
|
- [x] Unbekannte Platzhalter werden als Warnung angezeigt, blockieren aber das Speichern nicht
|
||||||
- [ ] Sortieren funktioniert am Handy bei 390 px Breite ohne horizontales Scrollen
|
- [x] Sortieren funktioniert am Handy bei 390 px Breite ohne horizontales Scrollen
|
||||||
- [ ] Der Markdown-Hilfetext rendert ohne rohes HTML
|
- [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.
|
||||||
|
|||||||
@@ -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 |
|
| #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 |
|
| #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 |
|
| #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 | – |
|
| #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 | – |
|
| #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 | – |
|
| #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 | – |
|
| #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 | – |
|
| #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 | – |
|
| #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** |
|
||||||
|
|||||||
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 275 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 339 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 510 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 253 KiB |
|
After Width: | Height: | Size: 118 KiB |
@@ -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.
|
// Die Reihenfolge dieser Liste ist die Reihenfolge in der Ansicht.
|
||||||
"abschnitte": [
|
"abschnitte": [
|
||||||
{
|
{
|
||||||
"titel": "01 · Vorbereitung",
|
"titel": "Vorbereitung",
|
||||||
"schritte": [
|
"schritte": [
|
||||||
{ "titel": "Gerät auspacken", "pflicht": true },
|
{ "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": [
|
"schritte": [
|
||||||
{
|
{
|
||||||
"titel": "Benutzerkonto anlegen",
|
"titel": "Benutzerkonto anlegen",
|
||||||
@@ -135,9 +135,17 @@ dass es jemand merkt.
|
|||||||
|
|
||||||
| Feld | Pflicht | Bedeutung |
|
| 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 `[]`. |
|
| `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
|
### Schritt
|
||||||
|
|
||||||
| Feld | Pflicht | Bedeutung |
|
| Feld | Pflicht | Bedeutung |
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"node": ">=24"
|
"node": ">=24"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"beispiel:vorlage": "tsx scripts/beispiel-vorlage.ts",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"db:down": "docker compose -f docker-compose.dev.yml down",
|
"db:down": "docker compose -f docker-compose.dev.yml down",
|
||||||
"db:generate": "prisma generate",
|
"db:generate": "prisma generate",
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
"docs:pdf": "tsx scripts/anleitung-pdf.ts",
|
"docs:pdf": "tsx scripts/anleitung-pdf.ts",
|
||||||
"docs:screenshots": "tsx scripts/screenshots.ts",
|
"docs:screenshots": "tsx scripts/screenshots.ts",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
|
"format:pruefen": "prettier --check .",
|
||||||
"issues:sync": "tsx scripts/gitea-issues.ts",
|
"issues:sync": "tsx scripts/gitea-issues.ts",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"postinstall": "prisma generate",
|
"postinstall": "prisma generate",
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -10,8 +10,13 @@
|
|||||||
* pnpm docs:screenshots --ziel m0 nach docs/screenshots/m0/
|
* pnpm docs:screenshots --ziel m0 nach docs/screenshots/m0/
|
||||||
* pnpm docs:screenshots --nur uebersicht nur diese Seite
|
* pnpm docs:screenshots --nur uebersicht nur diese Seite
|
||||||
*
|
*
|
||||||
* Voraussetzung: die App laeuft (pnpm start oder pnpm dev). Adresse ueber
|
* Voraussetzung: die App laeuft als **gebauter Stand** (pnpm build && pnpm start).
|
||||||
* E2E_APP_URL, Standard http://127.0.0.1:3000.
|
* 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 { mkdir } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
@@ -64,6 +69,35 @@ const SEITEN: readonly Seite[] = [
|
|||||||
},
|
},
|
||||||
{ name: "admin-benutzer", pfad: "/admin/benutzer" },
|
{ name: "admin-benutzer", pfad: "/admin/benutzer" },
|
||||||
{ name: "kunden", pfad: "/kunden", nurModus: "DIENSTLEISTUNG" },
|
{ 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 {
|
function argument(name: string): string | undefined {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,6 +22,9 @@ import { darf } from "@/server/auth/rollen";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { versionText } from "@/lib/version";
|
import { versionText } from "@/lib/version";
|
||||||
|
|
||||||
|
/** Wird in #014 auf `true` gestellt, zusammen mit der Seite. */
|
||||||
|
const SCANNEN_VERFUEGBAR = false;
|
||||||
|
|
||||||
const SYMBOLE: Record<string, LucideIcon> = {
|
const SYMBOLE: Record<string, LucideIcon> = {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
ListChecks,
|
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 }) {
|
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 (
|
return (
|
||||||
// Pille statt der shadcn-Vorgabe rounded-lg, und 44 px am Handy / 40 px am
|
<Button asChild className={gemeinsam}>
|
||||||
// Schreibtisch statt 32 px – so verlangt es die Gestaltungsvorgabe.
|
<Link href="/scannen">{inhalt}</Link>
|
||||||
<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>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ export type NavEintrag = {
|
|||||||
export const NAVIGATION: readonly NavEintrag[] = [
|
export const NAVIGATION: readonly NavEintrag[] = [
|
||||||
{ pfad: "/", beschriftung: "Übersicht", symbol: "LayoutDashboard", verfuegbar: true },
|
{ pfad: "/", beschriftung: "Übersicht", symbol: "LayoutDashboard", verfuegbar: true },
|
||||||
{ pfad: "/laeufe", beschriftung: "Läufe", symbol: "ListChecks", verfuegbar: false, ausIssue: 13 },
|
{ 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",
|
pfad: "/mitarbeiter",
|
||||||
beschriftung: "Mitarbeiter & Geräte",
|
beschriftung: "Mitarbeiter & Geräte",
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -104,6 +104,9 @@ export const AbschnittSchema = z.object({
|
|||||||
.default([]),
|
.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({
|
export const VariableSchema = z.object({
|
||||||
/**
|
/**
|
||||||
* Der Schluessel steht als `{{schluessel}}` in Titeln, Hilfetexten und
|
* 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
|
* soll, wenn ein Platzhalter zu keiner Variablen und zu keinem eingebauten Wert
|
||||||
* gehoert. Das Ersetzen selbst kommt in #012.
|
* 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[] {
|
export function platzhalterFinden(text: string): readonly string[] {
|
||||||
const gefunden = new Set<string>();
|
const gefunden = new Set<string>();
|
||||||
for (const treffer of text.matchAll(/\{\{\s*([a-z][a-z0-9_]*)\s*\}\}/g)) {
|
for (const treffer of text.matchAll(/\{\{\s*([a-z][a-z0-9_]*)\s*\}\}/g)) {
|
||||||
|
|||||||
@@ -33,6 +33,40 @@ export function istOeffentlich(pfad: string): boolean {
|
|||||||
return OEFFENTLICH.some((eintrag) => pfad === eintrag || pfad.startsWith(`${eintrag}/`));
|
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 {
|
export function proxy(anfrage: NextRequest): NextResponse {
|
||||||
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
|
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
|
||||||
const pfad = anfrage.nextUrl.pathname;
|
const pfad = anfrage.nextUrl.pathname;
|
||||||
@@ -44,21 +78,7 @@ export function proxy(anfrage: NextRequest): NextResponse {
|
|||||||
return NextResponse.redirect(anmelden);
|
return NextResponse.redirect(anmelden);
|
||||||
}
|
}
|
||||||
|
|
||||||
const richtlinie = [
|
const richtlinie = sicherheitsrichtlinie(nonce, process.env.NODE_ENV !== "production");
|
||||||
`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 kopfzeilen = new Headers(anfrage.headers);
|
const kopfzeilen = new Headers(anfrage.headers);
|
||||||
kopfzeilen.set("x-nonce", nonce);
|
kopfzeilen.set("x-nonce", nonce);
|
||||||
@@ -74,6 +94,16 @@ export function proxy(anfrage: NextRequest): NextResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
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?)$).*)"],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { z } from "@/lib/validierung";
|
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 { kundeZugriffPruefen, kundenEbeneSichtbar } from "@/server/mandant";
|
||||||
import { rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
import {
|
import {
|
||||||
VorlagenFehler,
|
VorlagenFehler,
|
||||||
entwurfHolenOderErzeugen,
|
entwurfHolenOderErzeugen,
|
||||||
@@ -36,10 +36,40 @@ function alsErgebnis(fehler: unknown, neutral: string): AktionsErgebnis {
|
|||||||
if (fehler instanceof VorlagenFehler || fehler instanceof AustauschFehler) {
|
if (fehler instanceof VorlagenFehler || fehler instanceof AustauschFehler) {
|
||||||
return { fehler: fehler.message };
|
return { fehler: fehler.message };
|
||||||
}
|
}
|
||||||
|
if (fehler instanceof RechteFehler) {
|
||||||
|
return { fehler: RECHTE_MELDUNG };
|
||||||
|
}
|
||||||
console.error(`${neutral}:`, fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : fehler);
|
console.error(`${neutral}:`, fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : fehler);
|
||||||
return { fehler: neutral };
|
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({
|
const AnlegenSchema = z.object({
|
||||||
name: z.string().trim().min(1, "Bitte einen Namen angeben."),
|
name: z.string().trim().min(1, "Bitte einen Namen angeben."),
|
||||||
beschreibung: z.string().trim().optional(),
|
beschreibung: z.string().trim().optional(),
|
||||||
@@ -127,7 +157,8 @@ export async function entwurfSpeichernAktion(
|
|||||||
_vorher: AktionsErgebnis,
|
_vorher: AktionsErgebnis,
|
||||||
formular: FormData,
|
formular: FormData,
|
||||||
): Promise<AktionsErgebnis & { readonly stand?: string }> {
|
): Promise<AktionsErgebnis & { readonly stand?: string }> {
|
||||||
await rechtErforderlich("vorlagenBearbeiten");
|
const zugang = await angemeldetOderMeldung();
|
||||||
|
if ("fehler" in zugang) return zugang;
|
||||||
|
|
||||||
const roh = formular.get("struktur");
|
const roh = formular.get("struktur");
|
||||||
let geparst: unknown;
|
let geparst: unknown;
|
||||||
@@ -145,7 +176,7 @@ export async function entwurfSpeichernAktion(
|
|||||||
if (!eingabe.success) {
|
if (!eingabe.success) {
|
||||||
const erster = eingabe.error.issues[0];
|
const erster = eingabe.error.issues[0];
|
||||||
return {
|
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);
|
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 {
|
try {
|
||||||
await vorlageBearbeiten(
|
await vorlageBearbeiten(
|
||||||
eingabe.data.vorlageId,
|
eingabe.data.vorlageId,
|
||||||
@@ -208,7 +249,7 @@ export async function vorlageBearbeitenAktion(
|
|||||||
.split(",")
|
.split(",")
|
||||||
.map((wort) => wort.trim())
|
.map((wort) => wort.trim())
|
||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
kundeId,
|
...(kundeAngegeben ? { kundeId } : {}),
|
||||||
},
|
},
|
||||||
benutzer.id,
|
benutzer.id,
|
||||||
);
|
);
|
||||||
@@ -223,7 +264,8 @@ export async function veroeffentlichenAktion(
|
|||||||
_vorher: AktionsErgebnis,
|
_vorher: AktionsErgebnis,
|
||||||
formular: FormData,
|
formular: FormData,
|
||||||
): Promise<AktionsErgebnis> {
|
): Promise<AktionsErgebnis> {
|
||||||
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
|
const zugang = await angemeldetOderMeldung();
|
||||||
|
if ("fehler" in zugang) return zugang;
|
||||||
const eingabe = z
|
const eingabe = z
|
||||||
.object({ versionId: z.string().min(1), aenderungsnotiz: z.string().trim().optional() })
|
.object({ versionId: z.string().min(1), aenderungsnotiz: z.string().trim().optional() })
|
||||||
.safeParse({
|
.safeParse({
|
||||||
@@ -236,7 +278,7 @@ export async function veroeffentlichenAktion(
|
|||||||
const version = await versionVeroeffentlichen(
|
const version = await versionVeroeffentlichen(
|
||||||
eingabe.data.versionId,
|
eingabe.data.versionId,
|
||||||
eingabe.data.aenderungsnotiz ?? null,
|
eingabe.data.aenderungsnotiz ?? null,
|
||||||
benutzer.id,
|
zugang.benutzerId,
|
||||||
);
|
);
|
||||||
revalidatePath("/vorlagen");
|
revalidatePath("/vorlagen");
|
||||||
return { erfolg: `Version ${version.version} ist veröffentlicht.` };
|
return { erfolg: `Version ${version.version} ist veröffentlicht.` };
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { Prisma } from "@/generated/prisma/client";
|
||||||
import { VersionStatus } from "@/generated/prisma/enums";
|
import { VersionStatus } from "@/generated/prisma/enums";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { platzhalterDerStruktur, type Struktur } from "@/lib/vorlagen-format";
|
import { platzhalterDerStruktur, type Struktur } from "@/lib/vorlagen-format";
|
||||||
@@ -19,6 +20,10 @@ export type VorlagenZeile = {
|
|||||||
readonly schlagworte: readonly string[];
|
readonly schlagworte: readonly string[];
|
||||||
readonly archiviert: boolean;
|
readonly archiviert: boolean;
|
||||||
readonly kundeId: string | null;
|
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. */
|
/** Nummer der zuletzt veroeffentlichten Version, falls es eine gibt. */
|
||||||
readonly veroeffentlichteVersion: number | null;
|
readonly veroeffentlichteVersion: number | null;
|
||||||
readonly veroeffentlichtAm: Date | null;
|
readonly veroeffentlichtAm: Date | null;
|
||||||
@@ -51,6 +56,55 @@ async function kundenBedingung(benutzer: HandelnderBenutzer) {
|
|||||||
return { OR: [{ kundeId: null }, { kundeId: { in: [...sichtbar] } }] };
|
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(
|
export async function vorlagenListe(
|
||||||
benutzer: HandelnderBenutzer,
|
benutzer: HandelnderBenutzer,
|
||||||
filter: VorlagenFilter = {},
|
filter: VorlagenFilter = {},
|
||||||
@@ -75,44 +129,29 @@ export async function vorlagenListe(
|
|||||||
const zeilen = await prisma.vorlage.findMany({
|
const zeilen = await prisma.vorlage.findMany({
|
||||||
where: { AND: bedingungen },
|
where: { AND: bedingungen },
|
||||||
orderBy: [{ archiviert: "asc" }, { name: "asc" }],
|
orderBy: [{ archiviert: "asc" }, { name: "asc" }],
|
||||||
select: {
|
select: ZEILEN_AUSWAHL,
|
||||||
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 } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
return zeilen.map(zeileBauen);
|
||||||
|
}
|
||||||
|
|
||||||
return zeilen.map((zeile) => {
|
/**
|
||||||
const veroeffentlichte = zeile.versionen
|
* Eine einzelne Vorlage mit ihren Kopfdaten.
|
||||||
.filter((v) => v.status === VersionStatus.VEROEFFENTLICHT)
|
*
|
||||||
.sort((a, b) => b.version - a.version);
|
* Bewusst dasselbe Lesemodell wie die Liste: die Vorlagenseite zeigt dieselben
|
||||||
const neueste = veroeffentlichte[0];
|
* Angaben, und zwei Modelle fuer dieselbe Zeile laufen mit der Zeit auseinander.
|
||||||
return {
|
* `undefined` heisst „gibt es nicht oder darfst du nicht sehen“ — die
|
||||||
id: zeile.id,
|
* Unterscheidung waere schon die Auskunft, die wir nicht geben wollen.
|
||||||
name: zeile.name,
|
*/
|
||||||
beschreibung: zeile.beschreibung,
|
export async function vorlageHolen(
|
||||||
kategorie: zeile.kategorie,
|
vorlageId: string,
|
||||||
schlagworte: zeile.schlagworte,
|
benutzer: HandelnderBenutzer,
|
||||||
archiviert: zeile.archiviert,
|
): Promise<VorlagenZeile | undefined> {
|
||||||
kundeId: zeile.kundeId,
|
const mandant = await kundenBedingung(benutzer);
|
||||||
veroeffentlichteVersion: neueste?.version ?? null,
|
const zeile = await prisma.vorlage.findFirst({
|
||||||
veroeffentlichtAm: neueste?.veroeffentlichtAm ?? null,
|
where: { id: vorlageId, ...(mandant ? mandant : {}) },
|
||||||
hatEntwurf: zeile.versionen.some((v) => v.status === VersionStatus.ENTWURF),
|
select: ZEILEN_AUSWAHL,
|
||||||
anzahlLaeufe: zeile.versionen.reduce((summe, v) => summe + v._count.laeufe, 0),
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
return zeile ? zeileBauen(zeile) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Die Kategorien und Schlagworte, die tatsaechlich vorkommen – fuer die Filter. */
|
/** Die Kategorien und Schlagworte, die tatsaechlich vorkommen – fuer die Filter. */
|
||||||
@@ -188,6 +227,12 @@ export type VersionMitAufbau = {
|
|||||||
readonly vorlageName: string;
|
readonly vorlageName: string;
|
||||||
readonly version: number;
|
readonly version: number;
|
||||||
readonly status: VersionStatus;
|
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;
|
readonly struktur: Struktur;
|
||||||
/** Platzhalter, die in Titeln, Hilfetexten und Befehlen vorkommen. */
|
/** Platzhalter, die in Titeln, Hilfetexten und Befehlen vorkommen. */
|
||||||
readonly platzhalter: readonly string[];
|
readonly platzhalter: readonly string[];
|
||||||
@@ -238,6 +283,8 @@ export async function versionMitAufbau(
|
|||||||
vorlageName: version.vorlage.name,
|
vorlageName: version.vorlage.name,
|
||||||
version: version.version,
|
version: version.version,
|
||||||
status: version.status,
|
status: version.status,
|
||||||
|
stand: version.geaendertAm,
|
||||||
|
aenderungsnotiz: version.aenderungsnotiz,
|
||||||
struktur,
|
struktur,
|
||||||
platzhalter: platzhalterDerStruktur(struktur),
|
platzhalter: platzhalterDerStruktur(struktur),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Prisma, type Vorlage, type VorlagenVersion } from "@/generated/prisma/client";
|
import { Prisma, type Vorlage, type VorlagenVersion } from "@/generated/prisma/client";
|
||||||
import { VersionStatus } from "@/generated/prisma/enums";
|
import { VersionStatus } from "@/generated/prisma/enums";
|
||||||
import { prisma } from "@/lib/db";
|
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).
|
* Vorlagen und ihre Versionierung (#009).
|
||||||
@@ -376,7 +376,7 @@ export async function entwurfSpeichern(
|
|||||||
if (!gelesen.success) {
|
if (!gelesen.success) {
|
||||||
const erster = gelesen.error.issues[0];
|
const erster = gelesen.error.issues[0];
|
||||||
throw new VorlagenFehler(
|
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;
|
const struktur = gelesen.data;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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("<script>");
|
||||||
|
});
|
||||||
|
|
||||||
|
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("<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="" />);
|
||||||
|
expect(html).not.toContain("<img");
|
||||||
|
expect(html).toContain("[Bild: Schrankplan]");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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 { darf } from "@/server/auth/rollen";
|
||||||
import type { Rolle } from "@/generated/prisma/enums";
|
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", () => {
|
it("hat keine doppelten Pfade je Modus", () => {
|
||||||
for (const modus of ["INTERN", "DIENSTLEISTUNG"] as const) {
|
for (const modus of ["INTERN", "DIENSTLEISTUNG"] as const) {
|
||||||
const pfade = pfadeFuer(modus, "ADMIN");
|
const pfade = pfadeFuer(modus, "ADMIN");
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { z } from "@/lib/validierung";
|
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).
|
* 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/);
|
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: ");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Betriebsmodus } from "@/generated/prisma/enums";
|
|||||||
import {
|
import {
|
||||||
versionMitAufbau,
|
versionMitAufbau,
|
||||||
versionsHistorie,
|
versionsHistorie,
|
||||||
|
vorlageHolen,
|
||||||
vorlagenFilterwerte,
|
vorlagenFilterwerte,
|
||||||
vorlagenListe,
|
vorlagenListe,
|
||||||
} from "@/server/queries/vorlagen";
|
} from "@/server/queries/vorlagen";
|
||||||
@@ -153,4 +154,42 @@ describe.runIf(datenbankVerfuegbar)("Mandantensicht auf Vorlagen", () => {
|
|||||||
expect(await vorlagenListe(techniker)).toHaveLength(3);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -174,7 +174,11 @@ describe.runIf(datenbankVerfuegbar)("Vorlagen-Versionierung", () => {
|
|||||||
).rejects.toThrow(/„upn“ gibt es zweimal/);
|
).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 benutzer = await benutzerAnlegen();
|
||||||
const { version } = await vorlageAnlegen({ name: "Probe" }, benutzer.id);
|
const { version } = await vorlageAnlegen({ name: "Probe" }, benutzer.id);
|
||||||
await expect(
|
await expect(
|
||||||
@@ -182,7 +186,7 @@ describe.runIf(datenbankVerfuegbar)("Vorlagen-Versionierung", () => {
|
|||||||
variablen: [],
|
variablen: [],
|
||||||
abschnitte: [{ titel: "Abschnitt", schritte: [{ titel: "gut" }, { titel: " " }] }],
|
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 () => {
|
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);
|
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 () => {
|
it("schreibt nichts, wenn sich nichts ändert", async () => {
|
||||||
const benutzer = await benutzerAnlegen();
|
const benutzer = await benutzerAnlegen();
|
||||||
const { vorlage } = await veroeffentlichteVorlage(benutzer.id);
|
const { vorlage } = await veroeffentlichteVorlage(benutzer.id);
|
||||||
|
|||||||
@@ -143,16 +143,31 @@ describe("Rechtepruefung an den Einstiegspunkten", () => {
|
|||||||
it("beginnt jede Server Action mit einer Rechtepruefung", () => {
|
it("beginnt jede Server Action mit einer Rechtepruefung", () => {
|
||||||
// Strukturell geprueft, weil eine vergessene Pruefung sonst erst auffiele,
|
// Strukturell geprueft, weil eine vergessene Pruefung sonst erst auffiele,
|
||||||
// wenn jemand sie ausnutzt. Die Rechtematrix selbst deckt tests/rollen.test.ts ab.
|
// 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[] = [];
|
const befunde: string[] = [];
|
||||||
|
|
||||||
for (const name of readdirSync(verzeichnis).filter((n) => n.endsWith(".ts"))) {
|
for (const name of readdirSync(verzeichnis).filter((n) => n.endsWith(".ts"))) {
|
||||||
const quelle = readFileSync(join(verzeichnis, name), "utf8");
|
const quelle = readFileSync(join(verzeichnis, name), "utf8");
|
||||||
expect(quelle.startsWith('"use server";'), `${name} fehlt "use server"`).toBe(true);
|
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)) {
|
for (const treffer of quelle.matchAll(/export async function (\w+)\(([\s\S]*?)\n\}/g)) {
|
||||||
const [, funktion, rumpf] = treffer;
|
const [, funktion, rumpf] = treffer;
|
||||||
if (!funktion || OEFFENTLICHE_AKTIONEN.has(funktion)) continue;
|
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`);
|
befunde.push(`${name}: ${funktion} prueft kein Recht`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -161,6 +176,38 @@ describe("Rechtepruefung an den Einstiegspunkten", () => {
|
|||||||
expect(befunde).toEqual([]);
|
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", () => {
|
it("findet ueberhaupt Aktionen, sonst prueft der Test oben nichts", () => {
|
||||||
const aktionen = readdirSync(verzeichnis)
|
const aktionen = readdirSync(verzeichnis)
|
||||||
.filter((n) => n.endsWith(".ts"))
|
.filter((n) => n.endsWith(".ts"))
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ export default defineConfig({
|
|||||||
alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },
|
alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },
|
||||||
},
|
},
|
||||||
test: {
|
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",
|
environment: "node",
|
||||||
globalSetup: ["tests/global-setup.ts"],
|
globalSetup: ["tests/global-setup.ts"],
|
||||||
setupFiles: ["tests/setup-env.ts"],
|
setupFiles: ["tests/setup-env.ts"],
|
||||||
|
|||||||