feat(vorlagen): Editor, Liste und Versionsgeschichte (#24)
@@ -47,6 +47,9 @@ jobs:
|
||||
- name: Code-Stil prüfen
|
||||
run: pnpm lint
|
||||
|
||||
- name: Format prüfen
|
||||
run: pnpm format:pruefen
|
||||
|
||||
- name: Typen prüfen
|
||||
run: pnpm typecheck
|
||||
|
||||
|
||||
@@ -57,6 +57,19 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||
- JSON-Export und -Import für Vorlagen, Format dokumentiert in `docs/vorlagen-format.md` (#009)
|
||||
- Zwei Redakteure am selben Entwurf treten sich nicht mehr auf die Füße: wer auf einem überholten
|
||||
Stand speichert, wird abgewiesen statt die Arbeit des anderen spurlos zu überschreiben (#009)
|
||||
- Vorlagenliste mit Suche, Filter nach Kategorie und Schlagwort und dem Kennzeichen
|
||||
„Entwurf vorhanden“; eine Vorlage lässt sich anlegen, duplizieren, archivieren und als JSON
|
||||
ein- und ausspielen (#010)
|
||||
- Vorlagen-Editor: Abschnitte, Schritte und Variablen anlegen, bearbeiten, löschen und sortieren
|
||||
— mit dem Finger, der Maus, der Tastatur oder zwei Knöpfen an jeder Zeile (#010)
|
||||
- Typabhängige Felder je Schritt: Auswahloptionen, Zahlenbereich mit Einheit, mehrzeiliger Text,
|
||||
Shell und Befehlstext; Hilfetexte als Markdown, dazu ein Doku-Link (#010)
|
||||
- Warnung im Editor, wenn ein Platzhalter zu keiner Variablen und zu keinem eingebauten Wert
|
||||
gehört — sie blockiert nichts, sie zeigt den Tippfehler, bevor der Techniker ihn im Lauf findet
|
||||
(#010)
|
||||
- Vorschau der Vorlage mit Beispielwerten, so wie ein Lauf sie zeigen wird (#010)
|
||||
- Versionsgeschichte je Vorlage: wer wann mit welcher Notiz veröffentlicht hat und wie viele Läufe
|
||||
an einer Version hängen (#010)
|
||||
|
||||
### Sicherheit
|
||||
|
||||
@@ -94,3 +107,14 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||
Aufbau einer Version geben nichts heraus, was der Benutzer nicht sehen darf (#009)
|
||||
- Der Doku-Link eines Schritts nimmt nur `http` und `https` an; über eine importierte Datei wäre
|
||||
ein `javascript:`-Link sonst fremder Code im Browser des Technikers (#009)
|
||||
- Hilfetexte werden als Markdown ohne rohes HTML gerendert — ein Skript im Hilfetext einer
|
||||
eingespielten Vorlage bleibt sichtbarer Text (#010)
|
||||
- Der Doku-Link eines Schritts wird auch in der Vorschau nur dann verlinkt, wenn er `http` oder
|
||||
`https` ist — dort hat ihn das Schema noch nicht gesehen (#010)
|
||||
- Die Content-Security-Policy lockert `unsafe-eval` und WebSocket-Verbindungen **nur** im
|
||||
Entwicklungsmodus; ein Test prüft die Entscheidung im Proxy selbst und nicht bloß die Funktion
|
||||
darunter (#010)
|
||||
- Eine abgelaufene Anmeldung beendet den Vorgang mit einer Meldung, statt die Seite auszuhängen —
|
||||
im Editor wäre sonst jede ungespeicherte Arbeit verloren gewesen (#010)
|
||||
- Eine archivierte Kundenzuordnung bleibt beim Speichern der Kopfdaten erhalten, statt sich
|
||||
stillschweigend in „für alle Kunden“ zu verwandeln (#010)
|
||||
|
||||
@@ -149,12 +149,37 @@ createdb -h 127.0.0.1 -p 5434 -U laufzettel laufzettel_review
|
||||
TEST_DATABASE_URL=postgresql://laufzettel:laufzettel@localhost:5434/laufzettel_review pnpm test
|
||||
```
|
||||
|
||||
## Oberfläche prüfen
|
||||
|
||||
**Immer gegen den gebauten Stand, nie gegen `pnpm dev`:**
|
||||
|
||||
```bash
|
||||
pnpm build && PORT=3300 APP_URL=http://127.0.0.1:3300 pnpm start
|
||||
```
|
||||
|
||||
In dieser Umgebung hydriert `next dev` nicht: die Seiten werden serverseitig
|
||||
gerendert und sehen vollständig richtig aus, aber React hängt sich nie an — kein
|
||||
Knopf, kein Formular, kein Sortieren tut etwas. Nachgewiesen in #010, und zwar
|
||||
auch ohne `src/proxy.ts` und ohne `output: "standalone"`; der HMR-WebSocket
|
||||
scheitert dort mit `ERR_INVALID_HTTP_RESPONSE`. Es liegt also am Werkzeug, nicht
|
||||
an diesem Code — der gebaute Stand hydriert einwandfrei (1281 von 1296 Elementen
|
||||
tragen React-Knoten, im Entwicklungsmodus 2).
|
||||
|
||||
Das ist heimtückisch: ein Screenshot beweist damit **nichts** über Bedienbarkeit.
|
||||
Wer eine Oberfläche abnimmt, klickt sie im gebauten Stand durch — hinzufügen,
|
||||
speichern, neu laden, und nachsehen, ob es wirklich in der Datenbank steht.
|
||||
`pnpm docs:screenshots` verlangt deshalb ebenfalls den gebauten Stand.
|
||||
|
||||
## Vor jedem Commit
|
||||
|
||||
```bash
|
||||
pnpm lint && pnpm typecheck && pnpm test && pnpm pruefe:doku
|
||||
pnpm lint && pnpm typecheck && pnpm format:pruefen && pnpm test && pnpm pruefe:doku
|
||||
```
|
||||
|
||||
`format:pruefen` gehört dazu, seit der Review in #010 darauf gestoßen ist: `lint-staged` formatiert
|
||||
beim Commit automatisch nach, und das Ergebnis wäre ein Commit mit Änderungen, die niemand gesehen
|
||||
hat. Lieber vorher sehen, was sich ändert.
|
||||
|
||||
Der Pre-Commit-Hook erzwingt Lint und Format, der Commit-Msg-Hook Conventional Commits mit
|
||||
höchstens 72 Zeichen im Betreff. Ein Commit pro abgeschlossener, lauffähiger Änderung. Kein
|
||||
`wip` auf `main`. Fußzeile referenziert das Issue: `Closes #12`.
|
||||
@@ -166,5 +191,20 @@ und aktive Zustände. Token stehen als CSS-Variablen in `src/app/globals.css` un
|
||||
Komponenten greifen nur darauf zu, nie auf rohe Hexwerte. Schriften selbst gehostet über
|
||||
`@fontsource-variable`. Icons ausschließlich Lucide-Stroke-SVGs — **keine Emojis**. Keine
|
||||
Verläufe, keine Schatten; Karten mit 1-px-Linie. Buttons als Pillen, 40 px hoch, am Handy 44–46 px.
|
||||
Abschnitte mit zweistelliger Nummer (`01 · Vorbereitung`). Druckansichten und Etiketten immer hell.
|
||||
Abschnitte mit zweistelliger Nummer (`01 · Vorbereitung`), die aus der Position
|
||||
kommt und nie gespeichert wird. Druckansichten und Etiketten immer hell.
|
||||
**Am Handy nie horizontales Scrollen** — bei 390 px geprüft.
|
||||
|
||||
> Der folgende Block ist **nicht von uns**: `next dev` schreibt ihn bei jedem Start selbst in diese
|
||||
> Datei und stellt ihn wieder her, wenn man ihn löscht. Er steht deshalb hier, auf Englisch, und
|
||||
> wird mitcommittet, damit der Arbeitsbaum sauber bleibt.
|
||||
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||
|
||||
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
|
||||
@@ -75,6 +75,16 @@ Editor, den JSON-Export und den Import — drei getrennte Schemata würden ausei
|
||||
Import wäre der Ort, an dem das erst auffiele. Das Format ist in
|
||||
[`docs/vorlagen-format.md`](vorlagen-format.md) beschrieben.
|
||||
|
||||
Die **eingebauten Platzhalter** stehen als Liste in [`src/lib/platzhalter.ts`](../src/lib/platzhalter.ts)
|
||||
— Daten, kein Code. Der Editor zeigt sie als Hilfe, die Warnung vor unbekannten Platzhaltern
|
||||
vergleicht dagegen, und die Auflösung in #012 baut darauf auf. Eine zweite Aufzählung an anderer
|
||||
Stelle würde unweigerlich auseinanderlaufen: ein Platzhalter, der in der Hilfe steht, aber nicht
|
||||
aufgelöst wird, bliebe im fertigen PowerShell-Befehl als `{{…}}` stehen.
|
||||
|
||||
Die Warnung ist ausdrücklich **keine Ablehnung**. Ein Tippfehler soll im Editor auffallen und nicht
|
||||
erst dem Techniker im Lauf; es kann aber gute Gründe geben, einen Platzhalter stehen zu lassen —
|
||||
etwa weil die Variable gleich noch kommt. Speichern und Veröffentlichen bleiben deshalb erlaubt.
|
||||
|
||||
## 5. Der Weg zu Phase 4 (Aktionen)
|
||||
|
||||
Die in den Vorlagen vorbereiteten PowerShell-Befehle sind bewusst die **Vorstufe ausführbarer
|
||||
@@ -118,6 +128,28 @@ gegen den Zweck eines Archivs. Der `tagCode` ist zusätzlich in `TagCodeVergabe`
|
||||
aus dem nie gelöscht wird — sonst würde ein Code nach dem Löschen eines Geräts wieder frei und
|
||||
ein alter Aufkleber zeigte auf ein fremdes Objekt.
|
||||
|
||||
## 5b. Warum ein Screenshot nichts über Bedienbarkeit sagt
|
||||
|
||||
In dieser Umgebung hydriert `next dev` nicht: die Seiten werden serverseitig gerendert und sehen
|
||||
vollständig richtig aus, aber React hängt sich nie an — kein Knopf, kein Formular, kein Sortieren
|
||||
tut etwas. Nachgewiesen in #010, und zwar auch ohne `src/proxy.ts` und ohne `output: "standalone"`;
|
||||
der HMR-WebSocket scheitert mit `ERR_INVALID_HTTP_RESPONSE`. Es liegt am Werkzeug, nicht an diesem
|
||||
Code — im gebauten Stand tragen 1281 von 1296 Elementen React-Knoten, im Entwicklungsmodus zwei.
|
||||
|
||||
Zwei Folgen, beide verbindlich:
|
||||
|
||||
1. **Oberflächen werden gegen `pnpm build && pnpm start` abgenommen**, nicht gegen `pnpm dev` —
|
||||
ebenso die Screenshots. Steht in `CLAUDE.md`.
|
||||
2. **Was nur mit angehängtem React gelingt, gehört in einen Playwright-Test.** `pnpm test:e2e`
|
||||
klickt den Editor durch: anlegen, tippen, sortieren mit dem Finger bei 390 px, speichern, neu
|
||||
laden und nachsehen, ob es wirklich in der Datenbank steht.
|
||||
|
||||
Die Content-Security-Policy in `src/proxy.ts` ist deshalb zweigeteilt: im Entwicklungsmodus mit
|
||||
`unsafe-eval` und WebSocket, im ausgelieferten Stand ohne beides. Die Entscheidung fällt an
|
||||
`process.env.NODE_ENV`, und `tests/sicherheitsrichtlinie.test.ts` prüft sie am Proxy selbst — eine
|
||||
Prüfung der Funktion mit selbst gesetztem Argument hätte genau den Fehler durchgelassen, vor dem
|
||||
der Kommentar darüber warnt.
|
||||
|
||||
## 6. Betrieb: zwei Wege, eine Regel
|
||||
|
||||
Die Datenbank ist auf beiden Wegen von außen nicht erreichbar:
|
||||
|
||||
@@ -12,11 +12,12 @@ als Unterpunkte mit Aufgabe und Dauer.
|
||||
| #007 | M2 – Auth, Rollen, Benutzer | 1 h 35 min |
|
||||
| #008 | M3 – Betriebsmodus und Kunden | 1 h 20 min |
|
||||
| #009 | M4 – Vorlagen-Versionierung im Service | 1 h 20 min |
|
||||
| | **Summe** | **6 h 50 min** |
|
||||
| #010 | M5 – Vorlagen-Editor | 2 h 10 min |
|
||||
| | **Summe** | **9 h** |
|
||||
|
||||
| Meilenstein | Tatsächlich | Status |
|
||||
|---|---|---|
|
||||
| v0.1.0 | 6 h 50 min | in Arbeit |
|
||||
| v0.1.0 | 9 h | in Arbeit |
|
||||
|
||||
---
|
||||
|
||||
@@ -339,3 +340,59 @@ als Unterpunkte mit Aufgabe und Dauer.
|
||||
|
||||
- #010 Vorlagen-Editor: Liste mit Suche und Filtern, Abschnitte und Schritte per Drag & Drop,
|
||||
Vorschau, Veröffentlichen, Versionshistorie, Warnung bei unbekannten Platzhaltern.
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-25 · Sitzung 6 · 08:20–10:30
|
||||
|
||||
**Issue:** #010 (M5)
|
||||
**Version:** 0.1.0
|
||||
|
||||
### Erledigt
|
||||
|
||||
- Vorlagenliste mit Suche und Filtern, Vorlagenseite mit Versionsgeschichte, Kopfdaten, Duplizieren,
|
||||
Archivieren, JSON-Ex- und -Import
|
||||
- Vorlagen-Editor (Sub-Agent): Abschnitte, Schritte, Variablen — anlegen, bearbeiten, löschen,
|
||||
sortieren mit Finger, Maus, Tastatur und Knöpfen; typabhängige Felder, Markdown-Hilfetexte,
|
||||
Platzhalter-Warnung, Vorschau mit Beispielwerten
|
||||
- `src/lib/platzhalter.ts` als einzige Quelle der eingebauten Platzhalter — #012 baut darauf auf
|
||||
- Erste Playwright-Tests: der Editor wird durch die Oberfläche gefahren, einschließlich Sortieren
|
||||
mit dem Finger bei 390 px
|
||||
- Unabhängiger Review: zwei Blocker, sechs weitere Befunde, alle abgearbeitet
|
||||
- **496 Tests** grün (vorher 428), dazu 5 E2E-Tests
|
||||
|
||||
### Entscheidungen
|
||||
|
||||
- **Oberflächen werden gegen den gebauten Stand abgenommen, nie gegen `pnpm dev`.** Der
|
||||
Entwicklungsmodus hydriert in dieser Umgebung nicht; ein Screenshot sieht deshalb richtig aus,
|
||||
während kein Knopf etwas tut. Das ändert die Arbeitsweise für alle folgenden Meilensteine und
|
||||
steht in `CLAUDE.md` und `docs/architektur.md`.
|
||||
- **Was nur mit angehängtem React gelingt, gehört in einen Playwright-Test.** Nicht erst in #018:
|
||||
ohne ihn wäre „Sortieren funktioniert am Handy“ eine Behauptung geblieben.
|
||||
- **Die Nummer eines Abschnitts kommt aus der Position und wird nie gespeichert.** Die
|
||||
Formatdokumentation sagte bisher das Gegenteil; beim ersten Umsortieren wäre eine gespeicherte
|
||||
Nummer falsch gewesen. Entfernt wird eine vorangestellte Nummer nur beim Trenner `·` und nur,
|
||||
wenn dahinter keine Ziffer steht — „24 · 7 Bereitschaft“ bleibt.
|
||||
- **Ein Knopf, der auf eine fehlende Seite zeigt, wird nicht verlinkt.** Galt für die Navigation
|
||||
schon, jetzt auch für „Scannen“.
|
||||
- **`format:pruefen` ist ein Tor.** Sonst formatiert `lint-staged` beim Commit nach, und im Commit
|
||||
stehen Änderungen, die niemand gesehen hat.
|
||||
|
||||
### Probleme
|
||||
|
||||
- **Mein eigener Prüfaufbau hat mich getäuscht.** Ich habe Seiten im Browser angesehen, für gut
|
||||
befunden und weitergemacht — in einem Entwicklungsmodus, in dem nichts anklickbar war. Aufgefallen
|
||||
ist es erst, als ich zum ersten Mal wirklich geklickt habe. Zwei Stunden Suche nach der Ursache,
|
||||
am Ende lag es am Werkzeug und nicht am Code; die Erkenntnis war die Zeit wert.
|
||||
- **Ein Kommentar hat wieder eine Zusage gemacht, die kein Test hielt** („ein Test hält das fest“,
|
||||
über der CSP). Dritter Meilenstein in Folge, in dem der Review genau diese Sorte Fehler findet.
|
||||
Ein Kommentar, der eine Garantie behauptet, braucht den Test daneben — oder den Satz nicht.
|
||||
- **Der Sub-Agent hat im Browser Dinge geprüft, die dort gar nicht prüfbar waren.** Mein Auftrag
|
||||
hat ihn dorthin geschickt; die Lücke war meine.
|
||||
- **Der Editor war bei einer einzigen Vorlage 16 986 px hoch.** Ich habe es im Screenshot gesehen,
|
||||
nicht der Review.
|
||||
|
||||
### Nächster Schritt
|
||||
|
||||
- #011 Mitarbeiter, Abteilungen und Geräte: Listen mit Suche, Mitarbeiterseite mit Historie,
|
||||
Abteilungen mit Ziel-OU und Standardgruppen, Geräte mit CSV-Import.
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# #010 M5 – Vorlagen-Editor
|
||||
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.1.0
|
||||
**Labels:** phase-1, frontend
|
||||
**Geschätzt:** 10 h **Tatsächlich:** –
|
||||
**Geschätzt:** 10 h **Tatsächlich:** 2 h 10 min
|
||||
|
||||
## Ziel
|
||||
|
||||
@@ -11,21 +11,68 @@ Der Editor, in dem ein Redakteur eine Vorlage baut, ohne den Kopf über Versione
|
||||
|
||||
## Aufgaben
|
||||
|
||||
- [ ] Vorlagenliste mit Suche, Filter nach Kategorie und Tag, Kennzeichnung „Entwurf vorhanden“
|
||||
- [ ] Editor: Abschnitte und Schritte anlegen, bearbeiten, löschen; Abschnitte mit zweistelliger Nummer (`01 · Vorbereitung`)
|
||||
- [ ] Sortieren per Drag & Drop mit `@dnd-kit`, auch am Handy mit dem Finger bedienbar
|
||||
- [ ] Je Schritt: Titel, Typ (`HAKEN`, `TEXT`, `ZAHL`, `AUSWAHL`, `DATUM`, `BEFEHL`), Pflicht (roter Stern), Hilfetext als Markdown, Doku-Link
|
||||
- [ ] Typabhängige `konfig`-Formulare: Auswahloptionen, Min/Max/Einheit, bei `BEFEHL` die Shell (`powershell`/`bash`/`cmd`), `multiline` und der Befehlstext
|
||||
- [ ] Variablen definieren: `key`, Label, Typ, Optionen, Pflicht, Position
|
||||
- [ ] Vorschau der Vorlage wie im Lauf, mit Beispielwerten
|
||||
- [ ] Veröffentlichen mit Änderungsnotiz, Versionshistorie ansehen, Vorlage duplizieren, JSON-Export und -Import
|
||||
- [ ] Warnung im Editor bei unbekannten Platzhaltern in Titel, Hilfetext und Befehlstext
|
||||
- [ ] Screenshots Liste, Editor, Historie in `docs/screenshots/m5/`
|
||||
- [x] Vorlagenliste mit Suche, Filter nach Kategorie und Tag, Kennzeichnung „Entwurf vorhanden“
|
||||
- [x] Editor: Abschnitte und Schritte anlegen, bearbeiten, löschen; Abschnitte mit zweistelliger Nummer (`01 · Vorbereitung`)
|
||||
- [x] Sortieren per Drag & Drop mit `@dnd-kit`, auch am Handy mit dem Finger bedienbar
|
||||
- [x] Je Schritt: Titel, Typ (`HAKEN`, `TEXT`, `ZAHL`, `AUSWAHL`, `DATUM`, `BEFEHL`), Pflicht (roter Stern), Hilfetext als Markdown, Doku-Link
|
||||
- [x] Typabhängige `konfig`-Formulare: Auswahloptionen, Min/Max/Einheit, bei `TEXT` mehrzeilig, bei `BEFEHL` die Shell (`powershell`/`bash`/`cmd`) und der Befehlstext
|
||||
- [x] Variablen definieren: `key`, Label, Typ, Optionen, Pflicht, Position
|
||||
- [x] Vorschau der Vorlage wie im Lauf, mit Beispielwerten
|
||||
- [x] Veröffentlichen mit Änderungsnotiz, Versionshistorie ansehen, Vorlage duplizieren, JSON-Export und -Import
|
||||
- [x] Warnung im Editor bei unbekannten Platzhaltern in Titel, Hilfetext und Befehlstext
|
||||
- [x] Screenshots Liste, Editor, Historie in `docs/screenshots/m5/`
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Eine Vorlage lässt sich vollständig ohne Umweg über die Datenbank anlegen, sortieren, veröffentlichen
|
||||
- [ ] Eine veröffentlichte Version ist im Editor nur lesend; „Bearbeiten“ erzeugt sichtbar einen Entwurf
|
||||
- [ ] Unbekannte Platzhalter werden als Warnung angezeigt, blockieren aber das Speichern nicht
|
||||
- [ ] Sortieren funktioniert am Handy bei 390 px Breite ohne horizontales Scrollen
|
||||
- [ ] Der Markdown-Hilfetext rendert ohne rohes HTML
|
||||
- [x] Eine Vorlage lässt sich vollständig ohne Umweg über die Datenbank anlegen, sortieren, veröffentlichen
|
||||
- [x] Eine veröffentlichte Version ist im Editor nur lesend; „Bearbeiten“ erzeugt sichtbar einen Entwurf
|
||||
- [x] Unbekannte Platzhalter werden als Warnung angezeigt, blockieren aber das Speichern nicht
|
||||
- [x] Sortieren funktioniert am Handy bei 390 px Breite ohne horizontales Scrollen
|
||||
- [x] Der Markdown-Hilfetext rendert ohne rohes HTML
|
||||
|
||||
## Beim Bauen aufgefallen
|
||||
|
||||
- **`next dev` hydriert in dieser Umgebung überhaupt nicht.** Die Seiten werden serverseitig
|
||||
gerendert und sehen vollständig richtig aus, aber React hängt sich nie an — kein Knopf tut etwas.
|
||||
Gemessen: im Entwicklungsmodus tragen 2 von 1302 Elementen React-Knoten, im gebauten Stand 1281
|
||||
von 1296. Es liegt nicht an diesem Code: auch ohne `src/proxy.ts` und ohne `output: "standalone"`
|
||||
bleibt es dabei, und der HMR-WebSocket scheitert mit `ERR_INVALID_HTTP_RESPONSE`. Das ist der
|
||||
heimtückischste Befund des Meilensteins, denn **ein Screenshot beweist damit nichts über
|
||||
Bedienbarkeit** — und genau darauf haben sich die Meilensteine #005 bis #009 verlassen. Seitdem
|
||||
steht in `CLAUDE.md`: Oberflächen werden gegen `pnpm build && pnpm start` abgenommen, und was nur
|
||||
mit angehängtem React gelingt, gehört in einen Playwright-Test.
|
||||
- **Die CSP hatte im Entwicklungsmodus kein `unsafe-eval`.** React braucht es dort, die Konsole
|
||||
meldete es auf jeder Seite. Behoben — es war nicht die Ursache des Hydrierungsproblems, aber ein
|
||||
echter Fehler. Der ausgelieferte Stand bleibt streng, und der Test dazu prüft jetzt den Proxy
|
||||
selbst statt nur die Funktion darunter: der Review hatte zu Recht beanstandet, dass der
|
||||
Kommentar eine Zusage machte, die kein Test hielt.
|
||||
- **Eine abgelaufene Anmeldung hätte im Editor eine Stunde Arbeit vernichtet.** `rechtErforderlich`
|
||||
wirft, der Wurf lief durch die Server Action bis in Nexts Fehlergrenze, und die hängt die Seite
|
||||
aus — samt allem, was noch nicht gespeichert war. Nach zwölf Stunden läuft die Sitzung von selbst
|
||||
ab; es hätte genau die Person getroffen, die lange an einer Vorlage sitzt. Jetzt wird der Fehler
|
||||
gefangen und als Meldung gezeigt, der Aufbau lässt sich bei **jedem** fehlgeschlagenen Speichern
|
||||
als JSON herauskopieren, und es gibt eine deutsche Fehlergrenze.
|
||||
- **Eine archivierte Kundenzuordnung verschwand beim Speichern der Kopfdaten.** Der archivierte
|
||||
Kunde fehlte in der Auswahlliste, der Browser wählte die erste Option („für alle Kunden“), und
|
||||
ein Klick auf „Kopfdaten speichern“ machte aus einer kundenspezifischen Vorlage eine für alle
|
||||
sichtbare. Genau das, was der Kommentar über der Unterscheidung „Feld fehlt“ gegen „Feld leer“
|
||||
zu verhindern versprach — er deckte den Fall „Feld da, aber unvollständig“ nicht ab.
|
||||
- **Zwei stille Textverfälscher.** `titelOhneNummer` machte aus „24 · 7 Bereitschaft“ ein
|
||||
„7 Bereitschaft“, und `mitBeispielwerten` wertete `$&` im **Ersatztext** als Rückverweis — in
|
||||
#012 wäre daraus ein verfälschter PowerShell-Befehl geworden.
|
||||
- **Der Editor war 16 986 px hoch**, weil jeder Schritt dauerhaft aufgeklappt war. Mit den sieben
|
||||
Vorlagen aus #017 (rund 150 Schritte) wäre er unbenutzbar gewesen. Jetzt klappen Schritte,
|
||||
Variablen und Abschnitte zu: dieselbe Vorlage misst 3 482 px am Handy.
|
||||
- **`abschnitte.2.schritte.0.titel` ist eine richtige und nutzlose Fehlermeldung.** Daraus wird
|
||||
jetzt „Abschnitt 03, Schritt 1: …“. Ein leeres Pflichtfeld weist weiterhin den ganzen Aufbau ab —
|
||||
aber man findet die Stelle.
|
||||
- **Der „Scannen“-Knopf zeigte auf eine Seite, die es nicht gibt.** Next holt Verweise beim
|
||||
Darüberfahren vorab, jede Seite erzeugte also im Hintergrund eine 404. Bis #014 ist er gedämpft
|
||||
und nicht verlinkt — dieselbe Regel, die für die Navigation schon galt.
|
||||
- **Eine veröffentlichte Vorlage lässt sich nicht mehr löschen**, auch nicht über einen
|
||||
Kaskadenlöschvorgang: der Trigger aus #009 verweigert es. Beim Aufräumen der Testdaten selbst
|
||||
erlebt. Das ist die Zusage, an der das Archiv hängt — die E2E-Tests archivieren deshalb, statt
|
||||
zu löschen, und brauchen ab #018 eine Wegwerf-Datenbank.
|
||||
- **`prettier --check` war rot**, ohne dass ein Tor es bemerkt hätte: `lint-staged` formatiert beim
|
||||
Commit automatisch nach, und das Ergebnis wäre ein Commit mit Änderungen, die niemand gesehen
|
||||
hat. `pnpm format:pruefen` gehört jetzt zu den Toren und zur CI.
|
||||
|
||||
@@ -14,7 +14,7 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
|
||||
| #007 | [M2 – Auth, Rollen, Benutzer](007-auth-rollen-benutzer.md) | erledigt | v0.1.0 | 8 h | 1 h 35 min |
|
||||
| #008 | [M3 – Betriebsmodus und Kunden](008-betriebsmodus-kunden.md) | erledigt | v0.1.0 | 6 h | 1 h 20 min |
|
||||
| #009 | [M4 – Vorlagen-Versionierung im Service](009-vorlagen-versionierung.md) | erledigt | v0.1.0 | 6 h | 1 h 20 min |
|
||||
| #010 | [M5 – Vorlagen-Editor](010-vorlagen-editor.md) | offen | v0.1.0 | 10 h | – |
|
||||
| #010 | [M5 – Vorlagen-Editor](010-vorlagen-editor.md) | erledigt | v0.1.0 | 10 h | 2 h 10 min |
|
||||
| #011 | [M6 – Mitarbeiter, Abteilungen, Geräte](011-mitarbeiter-abteilungen-geraete.md) | offen | v0.1.0 | 9 h | – |
|
||||
| #012 | [M7 – Platzhalter, Namensregeln, Escaping](012-platzhalter-namensregeln.md) | offen | v0.1.0 | 8 h | – |
|
||||
| #013 | [M8 – Läufe: Service und Abarbeitung](013-laeufe-abarbeitung.md) | offen | v0.1.0 | 14 h | – |
|
||||
@@ -24,4 +24,4 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
|
||||
| #017 | [M12 – Seed der sieben Vorlagen](017-seed-sieben-vorlagen.md) | offen | v0.1.0 | 8 h | – |
|
||||
| #018 | [M13 – E2E-Tests, Anleitung, PDF](018-e2e-anleitung-pdf.md) | offen | v0.1.0 | 14 h | – |
|
||||
| #019 | [M14 – Betriebswege und Release v0.1.0](019-betrieb-release.md) | offen | v0.1.0 | 10 h | – |
|
||||
| | **Summe** | | | **136 h** | **6 h 50 min** |
|
||||
| | **Summe** | | | **136 h** | **9 h** |
|
||||
|
||||
|
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.
|
||||
"abschnitte": [
|
||||
{
|
||||
"titel": "01 · Vorbereitung",
|
||||
"titel": "Vorbereitung",
|
||||
"schritte": [
|
||||
{ "titel": "Gerät auspacken", "pflicht": true },
|
||||
{
|
||||
@@ -75,7 +75,7 @@ Platzhaltern. **JSON kennt keine Kommentare** — die Zeilen mit `//` müssen vo
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "02 · Konto",
|
||||
"titel": "Konto",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Benutzerkonto anlegen",
|
||||
@@ -135,9 +135,17 @@ dass es jemand merkt.
|
||||
|
||||
| Feld | Pflicht | Bedeutung |
|
||||
| ---------- | -------- | ----------------------------------------------------------------------------- |
|
||||
| `titel` | Pflicht | Überschrift, nicht leer. Üblich mit zweistelliger Nummer: `01 · Vorbereitung`. |
|
||||
| `titel` | Pflicht | Überschrift, nicht leer und **ohne** Nummer — siehe unten. |
|
||||
| `schritte` | optional | Liste von Schritten in Abarbeitungsreihenfolge; Standard `[]`. |
|
||||
|
||||
Abschnitte werden überall mit einer zweistelligen Nummer angezeigt (`01 · Vorbereitung`). Diese
|
||||
Nummer wird **aus der Position abgeleitet und nie gespeichert**. Stünde sie im Titel, wäre sie beim
|
||||
ersten Umsortieren falsch — und niemand ginge hinterher durch und zöge sie nach. Trägt ein Titel
|
||||
dennoch eine vorangestellte Nummer mit `·` als Trenner (ältere Dateien taten das), wird sie beim
|
||||
Öffnen im Editor entfernt, damit nicht `01 · 01 · Vorbereitung` dasteht; mit dem nächsten Speichern
|
||||
gilt der bereinigte Titel. Andere Trenner bleiben unangetastet: „2. Etage prüfen“ ist ein Titel und
|
||||
keine Nummerierung.
|
||||
|
||||
### Schritt
|
||||
|
||||
| Feld | Pflicht | Bedeutung |
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"node": ">=24"
|
||||
},
|
||||
"scripts": {
|
||||
"beispiel:vorlage": "tsx scripts/beispiel-vorlage.ts",
|
||||
"build": "next build",
|
||||
"db:down": "docker compose -f docker-compose.dev.yml down",
|
||||
"db:generate": "prisma generate",
|
||||
@@ -20,6 +21,7 @@
|
||||
"docs:pdf": "tsx scripts/anleitung-pdf.ts",
|
||||
"docs:screenshots": "tsx scripts/screenshots.ts",
|
||||
"format": "prettier --write .",
|
||||
"format:pruefen": "prettier --check .",
|
||||
"issues:sync": "tsx scripts/gitea-issues.ts",
|
||||
"lint": "eslint",
|
||||
"postinstall": "prisma generate",
|
||||
|
||||
@@ -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 --nur uebersicht nur diese Seite
|
||||
*
|
||||
* Voraussetzung: die App laeuft (pnpm start oder pnpm dev). Adresse ueber
|
||||
* E2E_APP_URL, Standard http://127.0.0.1:3000.
|
||||
* Voraussetzung: die App laeuft als **gebauter Stand** (pnpm build && pnpm start).
|
||||
* Adresse ueber E2E_APP_URL, Standard http://127.0.0.1:3000.
|
||||
*
|
||||
* Ausdruecklich nicht gegen `pnpm dev`: dort haengt das Entwickler-Overlay im
|
||||
* Bild, und in dieser Umgebung hydriert der Entwicklungsmodus gar nicht — ein
|
||||
* Bild sieht dann richtig aus, zeigt aber eine Seite, auf der kein Knopf etwas
|
||||
* tut. Siehe CLAUDE.md, Abschnitt „Oberflaeche pruefen“.
|
||||
*/
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
@@ -64,6 +69,35 @@ const SEITEN: readonly Seite[] = [
|
||||
},
|
||||
{ name: "admin-benutzer", pfad: "/admin/benutzer" },
|
||||
{ name: "kunden", pfad: "/kunden", nurModus: "DIENSTLEISTUNG" },
|
||||
{ name: "vorlagen-liste", pfad: "/vorlagen" },
|
||||
/*
|
||||
* Vorlagenseite und Editor haengen an Kennungen, die je Datenbank anders sind.
|
||||
* Statt sie zu erraten, geht das Skript den Weg, den auch ein Redakteur geht:
|
||||
* von der Liste aus klicken. Bricht der Weg, bricht der Screenshot — und das
|
||||
* ist die richtige Antwort, denn dann ist die Oberflaeche kaputt.
|
||||
*/
|
||||
{
|
||||
name: "vorlagen-seite",
|
||||
pfad: "/vorlagen",
|
||||
vorbereiten: async (tab) => {
|
||||
await tab
|
||||
.getByRole("link", { name: /PC-Neuinstallation/ })
|
||||
.first()
|
||||
.click();
|
||||
await tab.waitForURL(/\/vorlagen\/[^/]+$/);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "vorlagen-editor",
|
||||
pfad: "/vorlagen",
|
||||
vorbereiten: async (tab) => {
|
||||
await tab
|
||||
.getByRole("link", { name: /Entwurf öffnen/ })
|
||||
.first()
|
||||
.click();
|
||||
await tab.waitForURL(/\/vorlagen\/version\/[^/]+$/);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function argument(name: string): string | undefined {
|
||||
|
||||
@@ -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 { versionText } from "@/lib/version";
|
||||
|
||||
/** Wird in #014 auf `true` gestellt, zusammen mit der Seite. */
|
||||
const SCANNEN_VERFUEGBAR = false;
|
||||
|
||||
const SYMBOLE: Record<string, LucideIcon> = {
|
||||
LayoutDashboard,
|
||||
ListChecks,
|
||||
@@ -90,15 +93,46 @@ function NavZeile({ eintrag, aktiv }: { eintrag: NavEintrag; aktiv: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Einstieg des Technikers, an beiden Stellen ganz oben.
|
||||
*
|
||||
* Solange es `/scannen` nicht gibt (#014), wird **nicht** verlinkt: ein Knopf,
|
||||
* der so prominent sitzt, darf nicht auf eine Fehlerseite fuehren. Next holt den
|
||||
* Verweis beim Darueberfahren sogar vorab — jede Seite erzeugte damit eine 404
|
||||
* im Hintergrund. Dieselbe Regel gilt schon fuer die Navigation.
|
||||
*/
|
||||
function ScannenKnopf({ className }: { className?: string }) {
|
||||
// Pille statt der shadcn-Vorgabe rounded-lg, und 44 px am Handy / 40 px am
|
||||
// Schreibtisch statt 32 px – so verlangt es die Gestaltungsvorgabe.
|
||||
const gemeinsam = cn("h-11 gap-2 rounded-pille px-4 text-sm font-semibold md:h-10", className);
|
||||
const inhalt = (
|
||||
<>
|
||||
<ScanLine className="size-[18px]" strokeWidth={2} aria-hidden="true" />
|
||||
Scannen
|
||||
</>
|
||||
);
|
||||
|
||||
if (!SCANNEN_VERFUEGBAR) {
|
||||
// Nicht als abgeblendeter Akzentknopf – das sieht aus wie ein Fehler. Die
|
||||
// gedaempfte Form ist dieselbe, die die Navigation fuer noch fehlende Seiten
|
||||
// benutzt.
|
||||
return (
|
||||
<span
|
||||
title="Kommt mit #014"
|
||||
aria-disabled="true"
|
||||
className={cn(
|
||||
gemeinsam,
|
||||
"flex cursor-not-allowed items-center justify-center border border-linie text-nebentext/60",
|
||||
)}
|
||||
>
|
||||
{inhalt}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
// Pille statt der shadcn-Vorgabe rounded-lg, und 44 px am Handy / 40 px am
|
||||
// Schreibtisch statt 32 px – so verlangt es die Gestaltungsvorgabe.
|
||||
<Button asChild className={cn("h-11 gap-2 rounded-pille px-4 text-sm font-semibold md:h-10", className)}>
|
||||
<Link href="/scannen">
|
||||
<ScanLine className="size-[18px]" strokeWidth={2} aria-hidden="true" />
|
||||
Scannen
|
||||
</Link>
|
||||
<Button asChild className={gemeinsam}>
|
||||
<Link href="/scannen">{inhalt}</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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[] = [
|
||||
{ pfad: "/", beschriftung: "Übersicht", symbol: "LayoutDashboard", verfuegbar: true },
|
||||
{ pfad: "/laeufe", beschriftung: "Läufe", symbol: "ListChecks", verfuegbar: false, ausIssue: 13 },
|
||||
{ pfad: "/vorlagen", beschriftung: "Vorlagen", symbol: "FileText", verfuegbar: false, ausIssue: 10 },
|
||||
{ pfad: "/vorlagen", beschriftung: "Vorlagen", symbol: "FileText", verfuegbar: true },
|
||||
{
|
||||
pfad: "/mitarbeiter",
|
||||
beschriftung: "Mitarbeiter & Geräte",
|
||||
|
||||
@@ -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([]),
|
||||
});
|
||||
|
||||
/** Ein Abschnitt so, wie ihn ein Editor oder eine Datei liefert – vor dem Pruefen. */
|
||||
export type AbschnittEingabe = z.input<typeof AbschnittSchema>;
|
||||
|
||||
export const VariableSchema = z.object({
|
||||
/**
|
||||
* Der Schluessel steht als `{{schluessel}}` in Titeln, Hilfetexten und
|
||||
@@ -176,6 +179,28 @@ export type Austausch = z.infer<typeof AustauschSchema>;
|
||||
* soll, wenn ein Platzhalter zu keiner Variablen und zu keinem eingebauten Wert
|
||||
* gehoert. Das Ersetzen selbst kommt in #012.
|
||||
*/
|
||||
/**
|
||||
* Aus einem Zod-Pfad eine Stelle machen, die man auf dem Bildschirm wiederfindet.
|
||||
*
|
||||
* `struktur.abschnitte.2.schritte.0.titel` ist fuer den Aufbau richtig und fuer
|
||||
* den Redakteur nutzlos — er muesste von null an zaehlen und wuesste immer noch
|
||||
* nicht, ob „2“ den dritten Abschnitt meint. Daraus wird „Abschnitt 03,
|
||||
* Schritt 1: “. Der Rest der Meldung kommt unveraendert aus dem Schema.
|
||||
*/
|
||||
export function stelleBenennen(pfad: readonly PropertyKey[]): string {
|
||||
const teile: string[] = [];
|
||||
for (let i = 0; i < pfad.length; i += 1) {
|
||||
const name = String(pfad[i]);
|
||||
const index = Number(pfad[i + 1]);
|
||||
if (!Number.isInteger(index)) continue;
|
||||
if (name === "abschnitte") teile.push(`Abschnitt ${String(index + 1).padStart(2, "0")}`);
|
||||
else if (name === "schritte") teile.push(`Schritt ${index + 1}`);
|
||||
else if (name === "variablen") teile.push(`Variable ${index + 1}`);
|
||||
else if (name === "optionen") teile.push(`Option ${index + 1}`);
|
||||
}
|
||||
return teile.length > 0 ? `${teile.join(", ")}: ` : "";
|
||||
}
|
||||
|
||||
export function platzhalterFinden(text: string): readonly string[] {
|
||||
const gefunden = new Set<string>();
|
||||
for (const treffer of text.matchAll(/\{\{\s*([a-z][a-z0-9_]*)\s*\}\}/g)) {
|
||||
|
||||
@@ -33,6 +33,40 @@ export function istOeffentlich(pfad: string): boolean {
|
||||
return OEFFENTLICH.some((eintrag) => pfad === eintrag || pfad.startsWith(`${eintrag}/`));
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Content-Security-Policy als reine Funktion — damit sie sich testen laesst.
|
||||
*
|
||||
* Im Entwicklungsmodus braucht es zwei Lockerungen, und nur dort:
|
||||
*
|
||||
* - **`unsafe-eval`**: React und Turbopack werten im Entwicklungsmodus Code zur
|
||||
* Laufzeit aus (Fast Refresh, Aufrufketten ueber Umgebungsgrenzen hinweg).
|
||||
* Ohne diese Erlaubnis **hydriert die Seite gar nicht** — sie sieht richtig
|
||||
* aus, aber kein Knopf tut etwas. Das ist der heimtueckische Teil: ein
|
||||
* Screenshot sieht aus wie immer, und erst ein Klick faellt auf.
|
||||
* - **`ws:` und `wss:`**: Fast Refresh haelt eine WebSocket-Verbindung.
|
||||
*
|
||||
* Im gebauten Stand braucht React beides nicht. Die ausgelieferte Anwendung
|
||||
* bekommt deshalb weiterhin die strenge Fassung — ein Test haelt das fest,
|
||||
* damit die Lockerung nicht eines Tages mitgeliefert wird.
|
||||
*/
|
||||
export function sicherheitsrichtlinie(nonce: string, entwicklung: boolean): string {
|
||||
return [
|
||||
`default-src 'self'`,
|
||||
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${entwicklung ? " 'unsafe-eval'" : ""}`,
|
||||
// Tailwind erzeugt Stilangaben zur Laufzeit, deshalb unsafe-inline fuer Stile.
|
||||
`style-src 'self' 'unsafe-inline'`,
|
||||
`img-src 'self' data: blob:`,
|
||||
`font-src 'self'`,
|
||||
// Die Kamera des Scanners liefert Bilddaten als blob:.
|
||||
`media-src 'self' blob:`,
|
||||
`connect-src 'self'${entwicklung ? " ws: wss:" : ""}`,
|
||||
`frame-ancestors 'none'`,
|
||||
`base-uri 'self'`,
|
||||
`form-action 'self'`,
|
||||
`object-src 'none'`,
|
||||
].join("; ");
|
||||
}
|
||||
|
||||
export function proxy(anfrage: NextRequest): NextResponse {
|
||||
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
|
||||
const pfad = anfrage.nextUrl.pathname;
|
||||
@@ -44,21 +78,7 @@ export function proxy(anfrage: NextRequest): NextResponse {
|
||||
return NextResponse.redirect(anmelden);
|
||||
}
|
||||
|
||||
const richtlinie = [
|
||||
`default-src 'self'`,
|
||||
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
|
||||
// Tailwind erzeugt Stilangaben zur Laufzeit, deshalb unsafe-inline fuer Stile.
|
||||
`style-src 'self' 'unsafe-inline'`,
|
||||
`img-src 'self' data: blob:`,
|
||||
`font-src 'self'`,
|
||||
// Die Kamera des Scanners liefert Bilddaten als blob:.
|
||||
`media-src 'self' blob:`,
|
||||
`connect-src 'self'`,
|
||||
`frame-ancestors 'none'`,
|
||||
`base-uri 'self'`,
|
||||
`form-action 'self'`,
|
||||
`object-src 'none'`,
|
||||
].join("; ");
|
||||
const richtlinie = sicherheitsrichtlinie(nonce, process.env.NODE_ENV !== "production");
|
||||
|
||||
const kopfzeilen = new Headers(anfrage.headers);
|
||||
kopfzeilen.set("x-nonce", nonce);
|
||||
@@ -74,6 +94,16 @@ export function proxy(anfrage: NextRequest): NextResponse {
|
||||
}
|
||||
|
||||
export const config = {
|
||||
// Statische Dateien und Bilder brauchen weder CSP-Nonce noch Tor.
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|webp|woff2?)$).*)"],
|
||||
/*
|
||||
* Alles unter `_next` bleibt aussen vor, nicht nur `static` und `image`.
|
||||
*
|
||||
* Die CSP gehoert auf das Dokument, nicht auf jeden Programmteil, und das Tor
|
||||
* schuetzt Seiten, keine Bausteine. Vorher lief der Proxy auch auf
|
||||
* `/_next/hmr` — der Verbindungsaufbau von Fast Refresh ist ein
|
||||
* WebSocket-Upgrade, und `NextResponse.next()` mit geaenderten Kopfzeilen
|
||||
* macht daraus eine gewoehnliche Antwort. Die Verbindung scheiterte, und im
|
||||
* Entwicklungsmodus hydrierte die Seite nicht mehr: sie sah richtig aus, aber
|
||||
* kein Knopf tat etwas.
|
||||
*/
|
||||
matcher: ["/((?!_next/|favicon.ico|.*\\.(?:svg|png|webp|woff2?)$).*)"],
|
||||
};
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "@/lib/validierung";
|
||||
import { StrukturSchema } from "@/lib/vorlagen-format";
|
||||
import { StrukturSchema, stelleBenennen } from "@/lib/vorlagen-format";
|
||||
import { kundeZugriffPruefen, kundenEbeneSichtbar } from "@/server/mandant";
|
||||
import { rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import {
|
||||
VorlagenFehler,
|
||||
entwurfHolenOderErzeugen,
|
||||
@@ -36,10 +36,40 @@ function alsErgebnis(fehler: unknown, neutral: string): AktionsErgebnis {
|
||||
if (fehler instanceof VorlagenFehler || fehler instanceof AustauschFehler) {
|
||||
return { fehler: fehler.message };
|
||||
}
|
||||
if (fehler instanceof RechteFehler) {
|
||||
return { fehler: RECHTE_MELDUNG };
|
||||
}
|
||||
console.error(`${neutral}:`, fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : fehler);
|
||||
return { fehler: neutral };
|
||||
}
|
||||
|
||||
const RECHTE_MELDUNG =
|
||||
"Deine Anmeldung gilt nicht mehr. Melde dich in einem zweiten Tab neu an und klicke dann hier " +
|
||||
"noch einmal — was du eingegeben hast, steht weiterhin auf dieser Seite.";
|
||||
|
||||
/**
|
||||
* Die Rechtepruefung so, dass ein abgelaufenes Cookie kein Datenverlust ist.
|
||||
*
|
||||
* `rechtErforderlich` wirft. Wird der Wurf nicht gefangen, endet er in Nexts
|
||||
* Fehlergrenze — die Seite wird ausgehaengt, und im Editor ist damit alles weg,
|
||||
* was noch nicht gespeichert war. Nach zwoelf Stunden laeuft die Sitzung von
|
||||
* selbst ab; das trifft genau die Person, die lange an einer Vorlage sitzt.
|
||||
*
|
||||
* Deshalb: fangen, und dem Benutzer sagen, was zu tun ist. Der Editor behaelt
|
||||
* seinen Zustand, weil die Aktion ganz normal ein Ergebnis zurueckgibt.
|
||||
*/
|
||||
async function angemeldetOderMeldung(): Promise<
|
||||
{ readonly benutzerId: string } | { readonly fehler: string }
|
||||
> {
|
||||
try {
|
||||
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
|
||||
return { benutzerId: benutzer.id };
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof RechteFehler) return { fehler: RECHTE_MELDUNG };
|
||||
throw fehler;
|
||||
}
|
||||
}
|
||||
|
||||
const AnlegenSchema = z.object({
|
||||
name: z.string().trim().min(1, "Bitte einen Namen angeben."),
|
||||
beschreibung: z.string().trim().optional(),
|
||||
@@ -127,7 +157,8 @@ export async function entwurfSpeichernAktion(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis & { readonly stand?: string }> {
|
||||
await rechtErforderlich("vorlagenBearbeiten");
|
||||
const zugang = await angemeldetOderMeldung();
|
||||
if ("fehler" in zugang) return zugang;
|
||||
|
||||
const roh = formular.get("struktur");
|
||||
let geparst: unknown;
|
||||
@@ -145,7 +176,7 @@ export async function entwurfSpeichernAktion(
|
||||
if (!eingabe.success) {
|
||||
const erster = eingabe.error.issues[0];
|
||||
return {
|
||||
fehler: erster ? `${erster.path.join(".")}: ${erster.message}` : "Der Aufbau ist unvollständig.",
|
||||
fehler: erster ? `${stelleBenennen(erster.path)}${erster.message}` : "Der Aufbau ist unvollständig.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -197,6 +228,16 @@ export async function vorlageBearbeitenAktion(
|
||||
await kundeZugriffPruefen(kundeId, benutzer);
|
||||
}
|
||||
|
||||
/*
|
||||
* Ein fehlendes Feld heisst „nicht angefasst“, ein leeres heisst „fuer alle“.
|
||||
*
|
||||
* Im Modus Intern zeigt das Formular gar keine Kundenwahl. Ohne diese
|
||||
* Unterscheidung haette jedes Speichern der Kopfdaten die Kundenzuordnung
|
||||
* stillschweigend geloescht — und nach einem Wechsel zurueck in den Modus
|
||||
* Dienstleistung waere die Vorlage ploetzlich fuer alle sichtbar gewesen.
|
||||
*/
|
||||
const kundeAngegeben = formular.has("kundeId");
|
||||
|
||||
try {
|
||||
await vorlageBearbeiten(
|
||||
eingabe.data.vorlageId,
|
||||
@@ -208,7 +249,7 @@ export async function vorlageBearbeitenAktion(
|
||||
.split(",")
|
||||
.map((wort) => wort.trim())
|
||||
.filter(Boolean),
|
||||
kundeId,
|
||||
...(kundeAngegeben ? { kundeId } : {}),
|
||||
},
|
||||
benutzer.id,
|
||||
);
|
||||
@@ -223,7 +264,8 @@ export async function veroeffentlichenAktion(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis> {
|
||||
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
|
||||
const zugang = await angemeldetOderMeldung();
|
||||
if ("fehler" in zugang) return zugang;
|
||||
const eingabe = z
|
||||
.object({ versionId: z.string().min(1), aenderungsnotiz: z.string().trim().optional() })
|
||||
.safeParse({
|
||||
@@ -236,7 +278,7 @@ export async function veroeffentlichenAktion(
|
||||
const version = await versionVeroeffentlichen(
|
||||
eingabe.data.versionId,
|
||||
eingabe.data.aenderungsnotiz ?? null,
|
||||
benutzer.id,
|
||||
zugang.benutzerId,
|
||||
);
|
||||
revalidatePath("/vorlagen");
|
||||
return { erfolg: `Version ${version.version} ist veröffentlicht.` };
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { VersionStatus } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { platzhalterDerStruktur, type Struktur } from "@/lib/vorlagen-format";
|
||||
@@ -19,6 +20,10 @@ export type VorlagenZeile = {
|
||||
readonly schlagworte: readonly string[];
|
||||
readonly archiviert: boolean;
|
||||
readonly kundeId: string | null;
|
||||
/** Name des Kunden, wenn die Vorlage kundenspezifisch ist – fuer die Anzeige. */
|
||||
readonly kundeName: string | null;
|
||||
/** Kennung des vorhandenen Entwurfs, damit die Liste direkt dorthin verlinkt. */
|
||||
readonly entwurfVersionId: string | null;
|
||||
/** Nummer der zuletzt veroeffentlichten Version, falls es eine gibt. */
|
||||
readonly veroeffentlichteVersion: number | null;
|
||||
readonly veroeffentlichtAm: Date | null;
|
||||
@@ -51,6 +56,55 @@ async function kundenBedingung(benutzer: HandelnderBenutzer) {
|
||||
return { OR: [{ kundeId: null }, { kundeId: { in: [...sichtbar] } }] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Auswahl und Abbildung stehen getrennt, damit Liste und Einzelabruf garantiert
|
||||
* dieselben Felder liefern – zwei Modelle fuer dieselbe Zeile laufen mit der
|
||||
* Zeit auseinander.
|
||||
*/
|
||||
const ZEILEN_AUSWAHL = {
|
||||
id: true,
|
||||
name: true,
|
||||
beschreibung: true,
|
||||
kategorie: true,
|
||||
schlagworte: true,
|
||||
archiviert: true,
|
||||
kundeId: true,
|
||||
kunde: { select: { name: true } },
|
||||
versionen: {
|
||||
select: {
|
||||
id: true,
|
||||
version: true,
|
||||
status: true,
|
||||
veroeffentlichtAm: true,
|
||||
_count: { select: { laeufe: true } },
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.VorlageSelect;
|
||||
|
||||
type VorlagenRohzeile = Prisma.VorlageGetPayload<{ select: typeof ZEILEN_AUSWAHL }>;
|
||||
|
||||
function zeileBauen(zeile: VorlagenRohzeile): VorlagenZeile {
|
||||
const neueste = zeile.versionen
|
||||
.filter((version) => version.status === VersionStatus.VEROEFFENTLICHT)
|
||||
.sort((a, b) => b.version - a.version)[0];
|
||||
const entwurf = zeile.versionen.find((version) => version.status === VersionStatus.ENTWURF);
|
||||
return {
|
||||
id: zeile.id,
|
||||
name: zeile.name,
|
||||
beschreibung: zeile.beschreibung,
|
||||
kategorie: zeile.kategorie,
|
||||
schlagworte: zeile.schlagworte,
|
||||
archiviert: zeile.archiviert,
|
||||
kundeId: zeile.kundeId,
|
||||
kundeName: zeile.kunde?.name ?? null,
|
||||
entwurfVersionId: entwurf?.id ?? null,
|
||||
veroeffentlichteVersion: neueste?.version ?? null,
|
||||
veroeffentlichtAm: neueste?.veroeffentlichtAm ?? null,
|
||||
hatEntwurf: entwurf !== undefined,
|
||||
anzahlLaeufe: zeile.versionen.reduce((summe, version) => summe + version._count.laeufe, 0),
|
||||
};
|
||||
}
|
||||
|
||||
export async function vorlagenListe(
|
||||
benutzer: HandelnderBenutzer,
|
||||
filter: VorlagenFilter = {},
|
||||
@@ -75,44 +129,29 @@ export async function vorlagenListe(
|
||||
const zeilen = await prisma.vorlage.findMany({
|
||||
where: { AND: bedingungen },
|
||||
orderBy: [{ archiviert: "asc" }, { name: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
beschreibung: true,
|
||||
kategorie: true,
|
||||
schlagworte: true,
|
||||
archiviert: true,
|
||||
kundeId: true,
|
||||
versionen: {
|
||||
select: {
|
||||
version: true,
|
||||
status: true,
|
||||
veroeffentlichtAm: true,
|
||||
_count: { select: { laeufe: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
select: ZEILEN_AUSWAHL,
|
||||
});
|
||||
return zeilen.map(zeileBauen);
|
||||
}
|
||||
|
||||
return zeilen.map((zeile) => {
|
||||
const veroeffentlichte = zeile.versionen
|
||||
.filter((v) => v.status === VersionStatus.VEROEFFENTLICHT)
|
||||
.sort((a, b) => b.version - a.version);
|
||||
const neueste = veroeffentlichte[0];
|
||||
return {
|
||||
id: zeile.id,
|
||||
name: zeile.name,
|
||||
beschreibung: zeile.beschreibung,
|
||||
kategorie: zeile.kategorie,
|
||||
schlagworte: zeile.schlagworte,
|
||||
archiviert: zeile.archiviert,
|
||||
kundeId: zeile.kundeId,
|
||||
veroeffentlichteVersion: neueste?.version ?? null,
|
||||
veroeffentlichtAm: neueste?.veroeffentlichtAm ?? null,
|
||||
hatEntwurf: zeile.versionen.some((v) => v.status === VersionStatus.ENTWURF),
|
||||
anzahlLaeufe: zeile.versionen.reduce((summe, v) => summe + v._count.laeufe, 0),
|
||||
};
|
||||
/**
|
||||
* Eine einzelne Vorlage mit ihren Kopfdaten.
|
||||
*
|
||||
* Bewusst dasselbe Lesemodell wie die Liste: die Vorlagenseite zeigt dieselben
|
||||
* Angaben, und zwei Modelle fuer dieselbe Zeile laufen mit der Zeit auseinander.
|
||||
* `undefined` heisst „gibt es nicht oder darfst du nicht sehen“ — die
|
||||
* Unterscheidung waere schon die Auskunft, die wir nicht geben wollen.
|
||||
*/
|
||||
export async function vorlageHolen(
|
||||
vorlageId: string,
|
||||
benutzer: HandelnderBenutzer,
|
||||
): Promise<VorlagenZeile | undefined> {
|
||||
const mandant = await kundenBedingung(benutzer);
|
||||
const zeile = await prisma.vorlage.findFirst({
|
||||
where: { id: vorlageId, ...(mandant ? mandant : {}) },
|
||||
select: ZEILEN_AUSWAHL,
|
||||
});
|
||||
return zeile ? zeileBauen(zeile) : undefined;
|
||||
}
|
||||
|
||||
/** Die Kategorien und Schlagworte, die tatsaechlich vorkommen – fuer die Filter. */
|
||||
@@ -188,6 +227,12 @@ export type VersionMitAufbau = {
|
||||
readonly vorlageName: string;
|
||||
readonly version: number;
|
||||
readonly status: VersionStatus;
|
||||
/**
|
||||
* Stand beim Laden. Der Editor schickt ihn beim Speichern zurueck; weicht er
|
||||
* ab, hat jemand anderes zwischenzeitlich gespeichert (#009).
|
||||
*/
|
||||
readonly stand: Date;
|
||||
readonly aenderungsnotiz: string | null;
|
||||
readonly struktur: Struktur;
|
||||
/** Platzhalter, die in Titeln, Hilfetexten und Befehlen vorkommen. */
|
||||
readonly platzhalter: readonly string[];
|
||||
@@ -238,6 +283,8 @@ export async function versionMitAufbau(
|
||||
vorlageName: version.vorlage.name,
|
||||
version: version.version,
|
||||
status: version.status,
|
||||
stand: version.geaendertAm,
|
||||
aenderungsnotiz: version.aenderungsnotiz,
|
||||
struktur,
|
||||
platzhalter: platzhalterDerStruktur(struktur),
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Prisma, type Vorlage, type VorlagenVersion } from "@/generated/prisma/client";
|
||||
import { VersionStatus } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { StrukturSchema, type Struktur, type StrukturEingabe } from "@/lib/vorlagen-format";
|
||||
import { stelleBenennen, StrukturSchema, type Struktur, type StrukturEingabe } from "@/lib/vorlagen-format";
|
||||
|
||||
/**
|
||||
* Vorlagen und ihre Versionierung (#009).
|
||||
@@ -376,7 +376,7 @@ export async function entwurfSpeichern(
|
||||
if (!gelesen.success) {
|
||||
const erster = gelesen.error.issues[0];
|
||||
throw new VorlagenFehler(
|
||||
erster ? `${erster.path.join(".")}: ${erster.message}` : "Der Aufbau ist unvollständig.",
|
||||
erster ? `${stelleBenennen(erster.path)}${erster.message}` : "Der Aufbau ist unvollständig.",
|
||||
);
|
||||
}
|
||||
const struktur = gelesen.data;
|
||||
|
||||
@@ -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 { navigationFuer } from "@/lib/navigation";
|
||||
import { existsSync } from "node:fs";
|
||||
import { NAVIGATION, navigationFuer } from "@/lib/navigation";
|
||||
import { darf } from "@/server/auth/rollen";
|
||||
import type { Rolle } from "@/generated/prisma/enums";
|
||||
|
||||
@@ -55,6 +56,19 @@ describe("Aufbau der Eintraege", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("hat zu jedem verfuegbaren Eintrag auch eine Seite", () => {
|
||||
// Das Umstellen von `verfuegbar` ist ein Handgriff im Meilenstein, der die
|
||||
// Seite liefert. Wird es zu frueh gemacht, zeigt die Navigation auf eine
|
||||
// 404 – und genau das faellt beim Klicken selten auf, weil man die eigene
|
||||
// neue Seite kennt und die anderen nicht anfasst.
|
||||
for (const eintrag of NAVIGATION) {
|
||||
if (!eintrag.verfuegbar) continue;
|
||||
const teil = eintrag.pfad === "/" ? "" : eintrag.pfad;
|
||||
const seite = new URL(`../src/app/(app)${teil}/page.tsx`, import.meta.url);
|
||||
expect(existsSync(seite), `${eintrag.pfad} ist verfuegbar, hat aber keine page.tsx`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("hat keine doppelten Pfade je Modus", () => {
|
||||
for (const modus of ["INTERN", "DIENSTLEISTUNG"] as const) {
|
||||
const pfade = pfadeFuer(modus, "ADMIN");
|
||||
|
||||
@@ -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 { z } from "@/lib/validierung";
|
||||
import { AustauschSchema, StrukturSchema } from "@/lib/vorlagen-format";
|
||||
import { AustauschSchema, StrukturSchema, stelleBenennen } from "@/lib/vorlagen-format";
|
||||
|
||||
/**
|
||||
* Zod ist in src/lib/validierung.ts auf Deutsch eingestellt (#009).
|
||||
@@ -32,3 +32,29 @@ describe("Deutsche Meldungen", () => {
|
||||
expect(ergebnis.error?.issues[0]?.message).toMatch(/Unbekanntes Format/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Stelle benennen", () => {
|
||||
it("macht aus einem Zod-Pfad eine Stelle im Editor", () => {
|
||||
expect(stelleBenennen(["struktur", "abschnitte", 2, "schritte", 0, "titel"])).toBe(
|
||||
"Abschnitt 03, Schritt 1: ",
|
||||
);
|
||||
expect(stelleBenennen(["variablen", 1, "schluessel"])).toBe("Variable 2: ");
|
||||
expect(stelleBenennen(["abschnitte", 0, "schritte", 3, "konfig", "optionen", 1])).toBe(
|
||||
"Abschnitt 01, Schritt 4, Option 2: ",
|
||||
);
|
||||
});
|
||||
|
||||
it("liefert nichts, wenn der Pfad keine Stelle beschreibt", () => {
|
||||
// Dann bleibt die Meldung des Schemas fuer sich allein stehen – besser als
|
||||
// ein leerer Doppelpunkt davor.
|
||||
expect(stelleBenennen(["name"])).toBe("");
|
||||
expect(stelleBenennen([])).toBe("");
|
||||
});
|
||||
|
||||
it("nennt die Stelle in der Reihenfolge, in der man sie sucht", () => {
|
||||
// Erst der Abschnitt, dann der Schritt darin – nicht umgekehrt.
|
||||
const stelle = stelleBenennen(["abschnitte", 9, "schritte", 11, "titel"]);
|
||||
expect(stelle.indexOf("Abschnitt")).toBeLessThan(stelle.indexOf("Schritt"));
|
||||
expect(stelle).toBe("Abschnitt 10, Schritt 12: ");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Betriebsmodus } from "@/generated/prisma/enums";
|
||||
import {
|
||||
versionMitAufbau,
|
||||
versionsHistorie,
|
||||
vorlageHolen,
|
||||
vorlagenFilterwerte,
|
||||
vorlagenListe,
|
||||
} from "@/server/queries/vorlagen";
|
||||
@@ -153,4 +154,42 @@ describe.runIf(datenbankVerfuegbar)("Mandantensicht auf Vorlagen", () => {
|
||||
expect(await vorlagenListe(techniker)).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Einzelne Vorlage", () => {
|
||||
it("gibt eine fremde Vorlage nicht heraus", async () => {
|
||||
// Die Vorlagenseite haengt allein an dieser Funktion. Gaebe sie die Zeile
|
||||
// heraus, staenden Name, Beschreibung und Versionsgeschichte offen – die
|
||||
// Kennung steht in jedem Link, den jemand weitergibt.
|
||||
const { techniker, fremde, meine, globale } = await aufbau();
|
||||
expect(await vorlageHolen(fremde.vorlage.id, techniker)).toBeUndefined();
|
||||
expect(await vorlageHolen(meine.vorlage.id, techniker)).toBeDefined();
|
||||
expect(await vorlageHolen(globale.vorlage.id, techniker)).toBeDefined();
|
||||
});
|
||||
|
||||
it("gibt einem Administrator auch eine fremde heraus", async () => {
|
||||
const { admin, fremde } = await aufbau();
|
||||
expect(await vorlageHolen(fremde.vorlage.id, admin)).toBeDefined();
|
||||
});
|
||||
|
||||
it("liefert dieselben Felder wie die Liste", async () => {
|
||||
// Zwei Lesemodelle fuer dieselbe Zeile laufen auseinander; deshalb bauen
|
||||
// beide aus derselben Auswahl. Der Test haelt das fest.
|
||||
const { techniker, meine } = await aufbau();
|
||||
const ausListe = (await vorlagenListe(techniker, { auchArchivierte: true })).find(
|
||||
(zeile) => zeile.id === meine.vorlage.id,
|
||||
);
|
||||
expect(await vorlageHolen(meine.vorlage.id, techniker)).toEqual(ausListe);
|
||||
});
|
||||
|
||||
it("findet auch eine archivierte Vorlage", async () => {
|
||||
// Die Seite muss sie zeigen koennen, sonst liesse sich nichts freigeben.
|
||||
const { techniker, meine } = await aufbau();
|
||||
await testPrisma.vorlage.update({
|
||||
where: { id: meine.vorlage.id },
|
||||
data: { archiviert: true },
|
||||
});
|
||||
const zeile = await vorlageHolen(meine.vorlage.id, techniker);
|
||||
expect(zeile?.archiviert).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -174,7 +174,11 @@ describe.runIf(datenbankVerfuegbar)("Vorlagen-Versionierung", () => {
|
||||
).rejects.toThrow(/„upn“ gibt es zweimal/);
|
||||
});
|
||||
|
||||
it("nennt bei einem Fehler im Aufbau den Pfad", async () => {
|
||||
it("nennt die Stelle so, wie sie im Editor steht", async () => {
|
||||
// „abschnitte.0.schritte.1.titel“ ist richtig und nutzlos: der Redakteur
|
||||
// muesste von null an zaehlen. Der Review hat es als den Weg benannt, auf
|
||||
// dem Arbeit wirklich verloren geht — mehrfach vergeblich speichern, dann
|
||||
// weggehen.
|
||||
const benutzer = await benutzerAnlegen();
|
||||
const { version } = await vorlageAnlegen({ name: "Probe" }, benutzer.id);
|
||||
await expect(
|
||||
@@ -182,7 +186,7 @@ describe.runIf(datenbankVerfuegbar)("Vorlagen-Versionierung", () => {
|
||||
variablen: [],
|
||||
abschnitte: [{ titel: "Abschnitt", schritte: [{ titel: "gut" }, { titel: " " }] }],
|
||||
}),
|
||||
).rejects.toThrow(/abschnitte\.0\.schritte\.1\.titel/);
|
||||
).rejects.toThrow("Abschnitt 01, Schritt 2: Ein Schritt braucht einen Titel.");
|
||||
});
|
||||
|
||||
it("hinterlässt bei einem Fehler keinen halben Entwurf", async () => {
|
||||
@@ -489,6 +493,25 @@ describe.runIf(datenbankVerfuegbar)("Vorlagen-Versionierung", () => {
|
||||
expect(await testPrisma.protokoll.count({ where: { aktion: "Vorlage archiviert" } })).toBe(1);
|
||||
});
|
||||
|
||||
it("lässt die Kundenzuordnung stehen, wenn sie nicht mitgeschickt wird", async () => {
|
||||
// Im Modus Intern zeigt das Formular keine Kundenwahl. Wuerde ein
|
||||
// fehlendes Feld als „auf leer setzen“ gelten, verlöre jede Vorlage beim
|
||||
// Speichern ihrer Kopfdaten die Zuordnung – und nach einem Wechsel zurueck
|
||||
// in den Modus Dienstleistung waere sie fuer alle sichtbar.
|
||||
const benutzer = await benutzerAnlegen();
|
||||
const kunde = await testPrisma.kunde.create({ data: { name: "Meiner", kuerzel: "MEIN" } });
|
||||
const { vorlage } = await vorlageAnlegen({ name: "Probe", kundeId: kunde.id }, benutzer.id);
|
||||
|
||||
await vorlageBearbeiten(vorlage.id, { name: "Anders" }, benutzer.id);
|
||||
expect((await testPrisma.vorlage.findUniqueOrThrow({ where: { id: vorlage.id } })).kundeId).toBe(
|
||||
kunde.id,
|
||||
);
|
||||
|
||||
// Ausdrücklich `null` loest sie dagegen.
|
||||
await vorlageBearbeiten(vorlage.id, { name: "Anders", kundeId: null }, benutzer.id);
|
||||
expect((await testPrisma.vorlage.findUniqueOrThrow({ where: { id: vorlage.id } })).kundeId).toBeNull();
|
||||
});
|
||||
|
||||
it("schreibt nichts, wenn sich nichts ändert", async () => {
|
||||
const benutzer = await benutzerAnlegen();
|
||||
const { vorlage } = await veroeffentlichteVorlage(benutzer.id);
|
||||
|
||||
@@ -143,16 +143,31 @@ describe("Rechtepruefung an den Einstiegspunkten", () => {
|
||||
it("beginnt jede Server Action mit einer Rechtepruefung", () => {
|
||||
// Strukturell geprueft, weil eine vergessene Pruefung sonst erst auffiele,
|
||||
// wenn jemand sie ausnutzt. Die Rechtematrix selbst deckt tests/rollen.test.ts ab.
|
||||
//
|
||||
// Anerkannt wird auch ein Helfer **in derselben Datei**, der seinerseits
|
||||
// `rechtErforderlich` ruft: seit #010 fangen die Vorlagen-Aktionen den
|
||||
// RechteFehler ab und geben eine Meldung zurueck, statt die Seite
|
||||
// auszuhaengen — im Editor waere sonst jede ungespeicherte Arbeit verloren.
|
||||
// Die Pruefung findet trotzdem statt, nur eine Ebene tiefer.
|
||||
const befunde: string[] = [];
|
||||
|
||||
for (const name of readdirSync(verzeichnis).filter((n) => n.endsWith(".ts"))) {
|
||||
const quelle = readFileSync(join(verzeichnis, name), "utf8");
|
||||
expect(quelle.startsWith('"use server";'), `${name} fehlt "use server"`).toBe(true);
|
||||
|
||||
// Helfer, die selbst pruefen – Name und Rumpf aus derselben Datei.
|
||||
const helfer = [...quelle.matchAll(/(?:async )?function (\w+)\(([\s\S]*?)\n\}/g)]
|
||||
.filter((treffer) => treffer[2]?.includes("rechtErforderlich("))
|
||||
.map((treffer) => treffer[1])
|
||||
.filter((funktion): funktion is string => funktion !== undefined);
|
||||
|
||||
for (const treffer of quelle.matchAll(/export async function (\w+)\(([\s\S]*?)\n\}/g)) {
|
||||
const [, funktion, rumpf] = treffer;
|
||||
if (!funktion || OEFFENTLICHE_AKTIONEN.has(funktion)) continue;
|
||||
if (!rumpf?.includes("rechtErforderlich(")) {
|
||||
const prueft =
|
||||
rumpf?.includes("rechtErforderlich(") === true ||
|
||||
helfer.some((helfername) => rumpf?.includes(`${helfername}(`) === true);
|
||||
if (!prueft) {
|
||||
befunde.push(`${name}: ${funktion} prueft kein Recht`);
|
||||
}
|
||||
}
|
||||
@@ -161,6 +176,38 @@ describe("Rechtepruefung an den Einstiegspunkten", () => {
|
||||
expect(befunde).toEqual([]);
|
||||
});
|
||||
|
||||
it("erkennt eine Aktion ohne jede Pruefung", () => {
|
||||
// Der Test oben ist nachsichtiger geworden; dieser hier zeigt, dass er
|
||||
// trotzdem noch anschlaegt. Geprueft wird die Regel, nicht die Datei.
|
||||
const quelle = [
|
||||
'"use server";',
|
||||
"async function mitPruefung() {",
|
||||
' await rechtErforderlich("vorlagenBearbeiten");',
|
||||
"}",
|
||||
"",
|
||||
"export async function gut() {",
|
||||
" await mitPruefung();",
|
||||
"}",
|
||||
"",
|
||||
"export async function schlecht() {",
|
||||
" return 42;",
|
||||
"}",
|
||||
].join("\n");
|
||||
|
||||
const helfer = [...quelle.matchAll(/(?:async )?function (\w+)\(([\s\S]*?)\n\}/g)]
|
||||
.filter((treffer) => treffer[2]?.includes("rechtErforderlich("))
|
||||
.map((treffer) => treffer[1]);
|
||||
const ohnePruefung = [...quelle.matchAll(/export async function (\w+)\(([\s\S]*?)\n\}/g)]
|
||||
.filter(
|
||||
([, , rumpf]) =>
|
||||
rumpf?.includes("rechtErforderlich(") !== true &&
|
||||
!helfer.some((name) => rumpf?.includes(`${name}(`) === true),
|
||||
)
|
||||
.map(([, funktion]) => funktion);
|
||||
|
||||
expect(ohnePruefung).toEqual(["schlecht"]);
|
||||
});
|
||||
|
||||
it("findet ueberhaupt Aktionen, sonst prueft der Test oben nichts", () => {
|
||||
const aktionen = readdirSync(verzeichnis)
|
||||
.filter((n) => n.endsWith(".ts"))
|
||||
|
||||
@@ -6,7 +6,9 @@ export default defineConfig({
|
||||
alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },
|
||||
},
|
||||
test: {
|
||||
include: ["tests/**/*.test.ts", "src/**/*.test.ts"],
|
||||
// `tsx` ist mitgemeint: Komponenten werden mit react-dom/server zu Text
|
||||
// gerendert und geprueft – dafuer braucht es keine DOM-Umgebung.
|
||||
include: ["tests/**/*.test.ts", "tests/**/*.test.tsx", "src/**/*.test.ts", "src/**/*.test.tsx"],
|
||||
environment: "node",
|
||||
globalSetup: ["tests/global-setup.ts"],
|
||||
setupFiles: ["tests/setup-env.ts"],
|
||||
|
||||