Die sieben Ablaeufe des Hauses liegen nach dem Seed als veroeffentlichte Version 1 bereit: 36 Abschnitte, 155 Schritte, 28 Variablen, jeder Schritt mit Hilfetext. Sie stehen als JSON im Austauschformat unter prisma/seed-daten/vorlagen/ und werden beim Einrichten gegen dasselbe Schema geprueft wie ein Import. Der Seed richtet ein, er raeumt nicht auf: jede Zeile hat einen sprechenden Schluessel, und was es schon gibt, bleibt unberuehrt. Erst Entwurf, dann veroeffentlichen - die Trigger aus #010 lassen keinen anderen Weg zu. Im Modus DIENSTLEISTUNG haengt der Beispielbestand an der Musterfirma, LZ-7K3F9Q fuehrt also auf MUS-PC-001. Dazu zwei Zusagen eingeloest, die niemand deckte: {{ou_gesperrt}} kommt jetzt wirklich aus den Einstellungen, und Titel und Hilfetext eines Laufs bekommen dasselbe generierte Passwort wie der Befehl daneben - vorher stand im Befehl A und im Hilfetext B. Closes #17 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
434 lines
15 KiB
TypeScript
434 lines
15 KiB
TypeScript
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",
|
|
ou_gesperrt: "OU=Gesperrt,OU=Benutzer,DC=muster,DC=example",
|
|
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;
|
|
}
|