Files
Laufzettel/src/components/vorlagen/editor.tsx
T
florian.wilde 855164066c
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(vorlagen): Abschnitte nur fuer bestimmte Abteilungen (#58)
2026-10-04 18:04:51 +00:00

562 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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,
bausteineAnreichern,
neuerAbschnitt,
neuerBausteinAbschnitt,
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 type EinsetzbarerBaustein = {
readonly bausteinVersionId: string;
readonly name: string;
readonly titel: string;
readonly version: number;
readonly anzahlSchritte: number;
};
export function Editor({
version,
lesend,
darfBearbeiten,
beispielDatum,
bausteine = [],
abteilungen = [],
}: {
version: EditorVersion;
lesend: boolean;
darfBearbeiten: boolean;
/** Heutiges Datum, auf dem Server formatiert — siehe `beispielwerte`. */
beispielDatum: string;
/** Veröffentlichte Bausteine, die sich einsetzen lassen (#030). */
bausteine?: readonly EinsetzbarerBaustein[];
/** Gepflegte Abteilungsnamen – für die Einschränkung je Abschnitt (#031). */
abteilungen?: readonly string[];
}) {
const router = useRouter();
const [aufbau, setzeAufbau] = useState<EditorAufbau>(() =>
bausteineAnreichern(alsEditorAufbau(version.struktur), bausteine),
);
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(bausteineAnreichern(alsEditorAufbau(version.struktur), bausteine))),
[version.struktur, bausteine],
);
/**
* 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}
abteilungen={abteilungen}
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 : (
<div className="flex flex-wrap items-center gap-3">
<TextKnopf
beschriftung={
abschnitteVoll ? `Höchstens ${GRENZEN.abschnitte} Abschnitte` : "Abschnitt hinzufügen"
}
aus={abschnitteVoll}
aufKlick={() => {
const abschnitt = neuerAbschnitt();
setzeZuletztAngelegt(abschnitt.kennung);
abschnitteSetzen([...aufbau.abschnitte, abschnitt]);
}}
/>
{/*
Bausteine nur anbieten, wenn es welche gibt. Ein leeres
Auswahlfeld mit „keine Bausteine“ wäre eine Frage ohne Antwort
(#030).
*/}
{bausteine.length > 0 ? (
<label className="flex min-w-0 items-center gap-2 text-sm">
<span className="shrink-0 text-nebentext">Baustein einsetzen</span>
<select
aria-label="Baustein einsetzen"
value=""
disabled={abschnitteVoll}
onChange={(ereignis) => {
const gewaehlt = bausteine.find(
(eintrag) => eintrag.bausteinVersionId === ereignis.target.value,
);
if (!gewaehlt) return;
const abschnitt = neuerBausteinAbschnitt(gewaehlt);
setzeZuletztAngelegt(abschnitt.kennung);
abschnitteSetzen([...aufbau.abschnitte, abschnitt]);
}}
className="min-h-11 min-w-0 rounded-pille border border-linie bg-flaeche-hoch px-4 text-sm md:min-h-10"
>
<option value="">auswählen …</option>
{bausteine.map((eintrag) => (
<option key={eintrag.bausteinVersionId} value={eintrag.bausteinVersionId}>
{eintrag.name} (Fassung {eintrag.version}, {eintrag.anzahlSchritte}{" "}
{eintrag.anzahlSchritte === 1 ? "Schritt" : "Schritte"})
</option>
))}
</select>
</label>
) : null}
</div>
)}
</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>
);
}