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

This commit was merged in pull request #24.
This commit is contained in:
2026-09-25 08:23:22 +00:00
parent d8d5444e9f
commit e3d80e5982
65 changed files with 5454 additions and 103 deletions
+208
View File
@@ -0,0 +1,208 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { ArrowLeft, Eye } from "lucide-react";
import { VorlageKopfdaten } from "@/components/vorlage-kopfdaten";
import { VorlageBearbeitenKnopf } from "@/components/vorlage-bearbeiten-knopf";
import {
EntwurfVerwerfen,
VersionExportieren,
VorlageArchivieren,
VorlageDuplizieren,
} from "@/components/vorlage-aktionen";
import { VersionStatus } from "@/generated/prisma/enums";
import { darf } from "@/server/auth/rollen";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { kundenEbeneSichtbar } from "@/server/mandant";
import { kundeHolen, kundenListe } from "@/server/queries/kunden";
import { versionsHistorie, vorlageHolen, type VersionsZeile } from "@/server/queries/vorlagen";
/**
* Eine Vorlage mit ihrer Versionsgeschichte (#010).
*
* Die Seite beantwortet drei Fragen auf einen Blick: was ist gerade gueltig, wird
* gerade daran gearbeitet, und was ist wann veroeffentlicht worden. Der Aufbau
* selbst steht im Editor — eine veroeffentlichte Version zeigt er nur lesend.
*/
const DATUM = new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
function HistorienZeile({ zeile, darfBearbeiten }: { zeile: VersionsZeile; darfBearbeiten: boolean }) {
const istEntwurf = zeile.status === VersionStatus.ENTWURF;
return (
<li className="flex flex-col gap-2 border-t border-linie py-3 first:border-t-0 md:flex-row md:items-start md:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="mono text-sm text-text">v{zeile.version}</span>
{istEntwurf ? (
<span className="rounded-pille bg-akzent-flaeche px-2.5 py-1 text-xs text-akzent">Entwurf</span>
) : (
<span className="text-xs text-nebentext">
veröffentlicht {zeile.veroeffentlichtAm ? DATUM.format(zeile.veroeffentlichtAm) : "–"}
{zeile.veroeffentlichtVon ? ` von ${zeile.veroeffentlichtVon}` : ""}
</span>
)}
<span className="text-xs text-nebentext">
{zeile.anzahlLaeufe} {zeile.anzahlLaeufe === 1 ? "Lauf" : "Läufe"}
</span>
</div>
{zeile.aenderungsnotiz ? (
<p className="mt-1 max-w-prose text-sm text-nebentext">{zeile.aenderungsnotiz}</p>
) : null}
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2">
<Link
href={`/vorlagen/version/${zeile.id}`}
className="flex min-h-11 items-center gap-2 rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10"
>
{istEntwurf ? (
"Im Editor öffnen"
) : (
<>
<Eye className="size-4" strokeWidth={1.75} aria-hidden="true" />
Ansehen
</>
)}
</Link>
{darfBearbeiten ? <VersionExportieren versionId={zeile.id} /> : null}
</div>
</li>
);
}
/**
* Die Kunden fuer das Kopfdaten-Formular — einschliesslich des zugeordneten,
* auch wenn er archiviert ist.
*
* Sonst faellt genau dieser Eintrag aus der Liste, `defaultValue` trifft nichts,
* der Browser waehlt die erste Option („für alle Kunden“), und ein Klick auf
* „Kopfdaten speichern“ loest die Zuordnung — aus einer Vorlage fuer einen
* Kunden wird eine fuer alle. Ein archivierter Kunde ist nicht geloescht; seine
* Vorlagen bleiben ihm zugeordnet.
*/
async function kundenAuswahl(
zugeordnet: string | null,
benutzer: Parameters<typeof kundenListe>[0],
): Promise<readonly { id: string; name: string }[]> {
const aktive = (await kundenListe(benutzer)).map(({ id, name }) => ({ id, name }));
if (!zugeordnet || aktive.some((kunde) => kunde.id === zugeordnet)) return aktive;
const archivierter = await kundeHolen(zugeordnet, benutzer);
/*
* Sieht der Benutzer den zugeordneten Kunden ueberhaupt nicht, laesst sich
* keine richtige Auswahl anbieten. Dann gibt es gar keine: eine leere Liste
* bedeutet „kein Feld im Formular“, und ein fehlendes Feld laesst die
* Zuordnung unangetastet (siehe actions/vorlagen.ts). Lieber nicht aenderbar
* als stillschweigend geloest.
*/
if (!archivierter) return [];
return [{ id: archivierter.id, name: `${archivierter.name} (archiviert)` }, ...aktive];
}
export default async function VorlagenSeite({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const { benutzer } = await sitzungErforderlich("lesen", `/vorlagen/${id}`);
const vorlage = await vorlageHolen(id, benutzer);
// Nicht vorhanden und nicht sichtbar sind absichtlich dieselbe Antwort – die
// Unterscheidung waere schon eine Auskunft ueber fremde Kunden (#009).
if (!vorlage) notFound();
const darfBearbeiten = darf(benutzer.rolle, "vorlagenBearbeiten");
const [historie, kundenEbene] = await Promise.all([
versionsHistorie(vorlage.id, benutzer),
kundenEbeneSichtbar(),
]);
const kunden = kundenEbene && darfBearbeiten ? await kundenAuswahl(vorlage.kundeId, benutzer) : [];
const entwurf = historie.find((zeile) => zeile.status === VersionStatus.ENTWURF);
return (
<>
<Link
href="/vorlagen"
className="inline-flex min-h-11 items-center gap-2 text-sm text-nebentext hover:text-text"
>
<ArrowLeft className="size-4" strokeWidth={1.75} aria-hidden="true" />
Alle Vorlagen
</Link>
<h1 className="mt-2 text-2xl font-bold">{vorlage.name}</h1>
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm text-nebentext">
{vorlage.veroeffentlichteVersion === null ? (
<span>noch keine veröffentlichte Version</span>
) : (
<span>
gültig ist <span className="mono text-text">v{vorlage.veroeffentlichteVersion}</span>
</span>
)}
{vorlage.kundeName ? <span>· nur {vorlage.kundeName}</span> : null}
{vorlage.archiviert ? <span>· archiviert</span> : null}
</div>
{vorlage.beschreibung ? (
<p className="mt-2 max-w-prose text-nebentext">{vorlage.beschreibung}</p>
) : null}
{darfBearbeiten ? (
<div className="mt-6 flex flex-col gap-3">
<VorlageBearbeitenKnopf vorlageId={vorlage.id} hatEntwurf={vorlage.hatEntwurf} />
{vorlage.archiviert ? (
<p className="max-w-prose text-sm text-faellig">
Diese Vorlage ist archiviert. Ein Entwurf lässt sich noch bearbeiten, veröffentlichen lässt er
sich erst nach dem Freigeben.
</p>
) : null}
</div>
) : null}
<section className="mt-8 rounded-karte border border-linie bg-flaeche p-5">
<h2 className="text-lg font-semibold">Versionen</h2>
<p className="mt-1 max-w-prose text-sm text-nebentext">
Eine veröffentlichte Version wird nie mehr verändert. Läufe halten sich an die Version, mit der sie
gestartet wurden.
</p>
<ul className="mt-4 flex flex-col">
{historie.map((zeile) => (
<HistorienZeile key={zeile.id} zeile={zeile} darfBearbeiten={darfBearbeiten} />
))}
</ul>
</section>
{darfBearbeiten ? (
<>
<section className="mt-4 rounded-karte border border-linie bg-flaeche p-5">
<h2 className="text-lg font-semibold">Kopfdaten</h2>
<p className="mt-1 mb-5 max-w-prose text-sm text-nebentext">
Name, Kategorie und Schlagworte hängen an der Vorlage, nicht an einer Version — sie lassen sich
jederzeit ändern, ohne etwas neu zu veröffentlichen.
</p>
<VorlageKopfdaten
vorlage={{
id: vorlage.id,
name: vorlage.name,
beschreibung: vorlage.beschreibung,
kategorie: vorlage.kategorie,
schlagworte: vorlage.schlagworte,
kundeId: vorlage.kundeId,
}}
kunden={kunden}
/>
</section>
<section className="mt-4 rounded-karte border border-linie bg-flaeche p-5">
<h2 className="text-lg font-semibold">Weiteres</h2>
<div className="mt-4 flex flex-col gap-3">
<VorlageDuplizieren vorlageId={vorlage.id} name={vorlage.name} />
<VorlageArchivieren vorlageId={vorlage.id} archiviert={vorlage.archiviert} />
{entwurf ? <EntwurfVerwerfen versionId={entwurf.id} /> : null}
</div>
</section>
</>
) : null}
</>
);
}
+230
View File
@@ -0,0 +1,230 @@
import Link from "next/link";
import { FileText, Pencil, Plus } from "lucide-react";
import { VorlageAnlegen } from "@/components/vorlage-anlegen";
import { VorlageImportieren } from "@/components/vorlage-importieren";
import { darf } from "@/server/auth/rollen";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { kundenEbeneSichtbar } from "@/server/mandant";
import { kundenListe } from "@/server/queries/kunden";
import { vorlagenFilterwerte, vorlagenListe, type VorlagenZeile } from "@/server/queries/vorlagen";
/**
* Vorlagenliste (#010).
*
* Die Filter laufen als GET-Formular ueber die Adresse, nicht ueber React-State:
* ein gesetzter Filter ist dann verlinkbar, der Zurueck-Knopf tut das Erwartete,
* und die Seite bleibt eine Server-Komponente, die nur die sichtbaren Zeilen
* ueberhaupt laedt. Die Mandantensicht wenden die Lesemodelle selbst an (#009).
*/
const FELD =
"min-h-11 rounded-karte border border-linie bg-grund px-3 text-sm text-text outline-none focus-visible:border-akzent";
function Kennzeichen({ zeile }: { zeile: VorlagenZeile }) {
return (
<div className="flex flex-wrap items-center gap-2">
{zeile.veroeffentlichteVersion === null ? (
<span className="rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext">
noch nicht veröffentlicht
</span>
) : (
<span className="mono rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext">
v{zeile.veroeffentlichteVersion}
</span>
)}
{zeile.hatEntwurf ? (
<span className="rounded-pille bg-akzent-flaeche px-2.5 py-1 text-xs text-akzent">
Entwurf vorhanden
</span>
) : null}
{zeile.archiviert ? (
<span className="rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext">archiviert</span>
) : null}
{zeile.kundeName ? (
<span className="rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext">
nur {zeile.kundeName}
</span>
) : null}
</div>
);
}
function Zeile({ zeile, darfBearbeiten }: { zeile: VorlagenZeile; darfBearbeiten: boolean }) {
return (
<li className="rounded-karte border border-linie bg-flaeche">
<div className="flex flex-col gap-3 p-4 md:flex-row md:items-start md:justify-between">
<div className="min-w-0">
<Link href={`/vorlagen/${zeile.id}`} className="group flex items-start gap-2">
<FileText
className="mt-0.5 size-[18px] shrink-0 text-nebentext"
strokeWidth={1.75}
aria-hidden="true"
/>
<span
className={`font-medium group-hover:text-akzent ${
zeile.archiviert ? "text-nebentext line-through" : ""
}`}
>
{zeile.name}
</span>
</Link>
{zeile.beschreibung ? (
<p className="mt-1 max-w-prose text-sm text-nebentext">{zeile.beschreibung}</p>
) : null}
<div className="mt-2">
<Kennzeichen zeile={zeile} />
</div>
<p className="mt-2 text-xs text-nebentext">
{[
zeile.kategorie,
...zeile.schlagworte,
`${zeile.anzahlLaeufe} ${zeile.anzahlLaeufe === 1 ? "Lauf" : "Läufe"}`,
]
.filter(Boolean)
.join(" · ")}
</p>
</div>
{/*
* Der Entwurf wird direkt verlinkt, wenn es einen gibt. Erst ueber die
* Vorlagenseite zu gehen waere ein Klick mehr fuer den haeufigsten Fall:
* weiterarbeiten an dem, was man gestern angefangen hat.
*/}
{darfBearbeiten && zeile.entwurfVersionId ? (
<Link
href={`/vorlagen/version/${zeile.entwurfVersionId}`}
className="flex min-h-11 shrink-0 items-center gap-2 rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10"
>
<Pencil className="size-4" strokeWidth={1.75} aria-hidden="true" />
Entwurf öffnen
</Link>
) : null}
</div>
</li>
);
}
export default async function VorlagenSeite({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const { benutzer } = await sitzungErforderlich("lesen", "/vorlagen");
const parameter = await searchParams;
const einzeln = (name: string): string =>
(Array.isArray(parameter[name]) ? parameter[name][0] : parameter[name])?.trim() ?? "";
const filter = {
suche: einzeln("suche"),
kategorie: einzeln("kategorie"),
schlagwort: einzeln("schlagwort"),
auchArchivierte: einzeln("archiviert") === "1",
};
const darfBearbeiten = darf(benutzer.rolle, "vorlagenBearbeiten");
const [zeilen, filterwerte, kundenEbene] = await Promise.all([
vorlagenListe(benutzer, filter),
vorlagenFilterwerte(benutzer),
kundenEbeneSichtbar(),
]);
// Die Kundenwahl im Anlegen-Formular gibt es nur im Modus Dienstleistung.
const kunden = kundenEbene ? await kundenListe(benutzer) : [];
return (
<>
<h1 className="text-2xl font-bold">Vorlagen</h1>
<p className="mt-2 max-w-prose text-nebentext">
Eine Vorlage wird veröffentlicht und ist danach unveränderlich. Ein Lauf hängt immer an der Version,
mit der er gestartet wurde — deshalb ändert eine neue Veröffentlichung nie einen laufenden oder
archivierten Lauf.
</p>
<form method="get" className="mt-6 flex flex-col gap-3 md:flex-row md:flex-wrap md:items-end">
<label className="flex min-w-0 flex-1 flex-col gap-1.5 text-sm md:max-w-xs">
<span className="text-nebentext">Suche</span>
<input
name="suche"
defaultValue={filter.suche}
placeholder="Name oder Beschreibung"
className={FELD}
/>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Kategorie</span>
<select name="kategorie" defaultValue={filter.kategorie} className={FELD}>
<option value="">alle</option>
{filterwerte.kategorien.map((kategorie) => (
<option key={kategorie} value={kategorie}>
{kategorie}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Schlagwort</span>
<select name="schlagwort" defaultValue={filter.schlagwort} className={FELD}>
<option value="">alle</option>
{filterwerte.schlagworte.map((wort) => (
<option key={wort} value={wort}>
{wort}
</option>
))}
</select>
</label>
<label className="flex min-h-11 items-center gap-2 text-sm text-nebentext">
<input type="checkbox" name="archiviert" value="1" defaultChecked={filter.auchArchivierte} />
auch archivierte
</label>
<button
type="submit"
className="min-h-11 rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10"
>
Filtern
</button>
{filter.suche || filter.kategorie || filter.schlagwort || filter.auchArchivierte ? (
<Link
href="/vorlagen"
className="flex min-h-11 items-center px-2 text-sm text-nebentext underline underline-offset-2 hover:text-text md:min-h-10"
>
zurücksetzen
</Link>
) : null}
</form>
{zeilen.length === 0 ? (
<p className="mt-8 rounded-karte border border-linie bg-flaeche p-5 text-sm text-nebentext">
Keine Vorlage passt zu diesen Filtern.
</p>
) : (
<ul className="mt-6 flex flex-col gap-3">
{zeilen.map((zeile) => (
<Zeile key={zeile.id} zeile={zeile} darfBearbeiten={darfBearbeiten} />
))}
</ul>
)}
{darfBearbeiten ? (
<div className="mt-8 flex flex-col gap-4">
<section className="rounded-karte border border-linie bg-flaeche p-5">
<h2 className="flex items-center gap-2 text-lg font-semibold">
<Plus className="size-5 text-akzent" strokeWidth={2} aria-hidden="true" />
Vorlage anlegen
</h2>
<p className="mt-1 mb-5 max-w-prose text-sm text-nebentext">
Die neue Vorlage beginnt als Entwurf. Erst das Veröffentlichen macht sie für Läufe verfügbar.
</p>
<VorlageAnlegen kunden={kunden.map(({ id, name }) => ({ id, name }))} />
</section>
<details className="rounded-karte border border-linie bg-flaeche">
<summary className="flex min-h-11 cursor-pointer list-none items-center px-5 text-sm text-nebentext hover:text-text">
Vorlage aus einer Datei einspielen
</summary>
<div className="px-5 pb-5">
<VorlageImportieren />
</div>
</details>
</div>
) : null}
</>
);
}
@@ -0,0 +1,53 @@
import { notFound } from "next/navigation";
import { Editor } from "@/components/vorlagen/editor";
import { VersionStatus } from "@/generated/prisma/enums";
import { darf } from "@/server/auth/rollen";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { versionMitAufbau } from "@/server/queries/vorlagen";
/**
* Eine Version einer Vorlage im Editor (#010).
*
* Die Seite prueft ihr Recht selbst — die Huelle darunter ist das zweite Netz,
* nicht das erste. `lesen` genuegt zum Ansehen; geaendert werden darf nur ein
* Entwurf und nur mit `vorlagenBearbeiten`. Beides entscheidet hier der Server,
* der `lesend`-Schalter im Editor ist nur die Anzeige davon: die Server Actions
* pruefen dasselbe noch einmal.
*
* `undefined` aus `versionMitAufbau` heisst „gibt es nicht oder darfst du nicht
* sehen“ — die Unterscheidung waere schon die Auskunft, die wir nicht geben.
*/
export default async function VersionSeite({ params }: { params: Promise<{ versionId: string }> }) {
const { versionId } = await params;
const { benutzer } = await sitzungErforderlich("lesen", `/vorlagen/version/${versionId}`);
const version = await versionMitAufbau(versionId, benutzer);
if (!version) notFound();
const darfBearbeiten = darf(benutzer.rolle, "vorlagenBearbeiten");
const veroeffentlicht = version.status === VersionStatus.VEROEFFENTLICHT;
return (
<Editor
version={{
id: version.id,
vorlageId: version.vorlageId,
vorlageName: version.vorlageName,
version: version.version,
veroeffentlicht,
struktur: version.struktur,
stand: version.stand.toISOString(),
aenderungsnotiz: version.aenderungsnotiz,
}}
lesend={veroeffentlicht || !darfBearbeiten}
darfBearbeiten={darfBearbeiten}
// Auf dem Server formatiert: im Browser waere es eine zweite Uhrzeit und
// damit ein Unterschied zwischen Vorrendern und Hydrieren.
beispielDatum={new Date().toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
})}
/>
);
}
+53
View File
@@ -0,0 +1,53 @@
"use client";
import Link from "next/link";
import { useEffect } from "react";
/**
* Die Fehlergrenze der Anwendung – auf Deutsch und mit einem Weg zurueck.
*
* Ohne sie zeigt Next seine englische Standardseite („This page couldn’t load“),
* und der Benutzer steht ohne Hinweis da. Sie ist bewusst die **zweite**
* Verteidigungslinie: Aktionen fangen ihre Fehler selbst und geben eine Meldung
* zurueck, damit die Seite gar nicht erst ausgehaengt wird und Eingaben stehen
* bleiben. Was hier ankommt, ist Unvorhergesehenes.
*/
export default function Fehlergrenze({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Der Text des Fehlers gehoert nicht auf den Bildschirm – er kann Interna
// enthalten. In die Browserkonsole gehoert er, fuer die Fehlersuche.
console.error("Unbehandelter Fehler:", error);
}, [error]);
return (
<main className="flex min-h-dvh flex-col items-center justify-center gap-4 p-6 text-center">
<h1 className="text-2xl font-bold">Da ist etwas schiefgegangen</h1>
<p className="max-w-prose text-nebentext">
Der Vorgang ließ sich nicht zu Ende bringen. Ein zweiter Versuch hilft oft; bleibt es dabei, hilft der
Hinweis unten bei der Fehlersuche.
</p>
{error.digest ? <p className="mono text-xs text-nebentext">Kennung: {error.digest}</p> : null}
<div className="flex flex-wrap items-center justify-center gap-3">
<button
type="button"
onClick={reset}
className="flex min-h-11 items-center rounded-pille bg-akzent px-5 text-sm font-semibold text-auf-akzent md:min-h-10"
>
Noch einmal versuchen
</button>
<Link
href="/"
className="flex min-h-11 items-center rounded-pille border border-linie px-5 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10"
>
Zur Übersicht
</Link>
</div>
</main>
);
}
+41 -7
View File
@@ -22,6 +22,9 @@ import { darf } from "@/server/auth/rollen";
import { cn } from "@/lib/utils";
import { versionText } from "@/lib/version";
/** Wird in #014 auf `true` gestellt, zusammen mit der Seite. */
const SCANNEN_VERFUEGBAR = false;
const SYMBOLE: Record<string, LucideIcon> = {
LayoutDashboard,
ListChecks,
@@ -90,15 +93,46 @@ function NavZeile({ eintrag, aktiv }: { eintrag: NavEintrag; aktiv: boolean }) {
);
}
/**
* Der Einstieg des Technikers, an beiden Stellen ganz oben.
*
* Solange es `/scannen` nicht gibt (#014), wird **nicht** verlinkt: ein Knopf,
* der so prominent sitzt, darf nicht auf eine Fehlerseite fuehren. Next holt den
* Verweis beim Darueberfahren sogar vorab — jede Seite erzeugte damit eine 404
* im Hintergrund. Dieselbe Regel gilt schon fuer die Navigation.
*/
function ScannenKnopf({ className }: { className?: string }) {
// Pille statt der shadcn-Vorgabe rounded-lg, und 44 px am Handy / 40 px am
// Schreibtisch statt 32 px – so verlangt es die Gestaltungsvorgabe.
const gemeinsam = cn("h-11 gap-2 rounded-pille px-4 text-sm font-semibold md:h-10", className);
const inhalt = (
<>
<ScanLine className="size-[18px]" strokeWidth={2} aria-hidden="true" />
Scannen
</>
);
if (!SCANNEN_VERFUEGBAR) {
// Nicht als abgeblendeter Akzentknopf – das sieht aus wie ein Fehler. Die
// gedaempfte Form ist dieselbe, die die Navigation fuer noch fehlende Seiten
// benutzt.
return (
<span
title="Kommt mit #014"
aria-disabled="true"
className={cn(
gemeinsam,
"flex cursor-not-allowed items-center justify-center border border-linie text-nebentext/60",
)}
>
{inhalt}
</span>
);
}
return (
// Pille statt der shadcn-Vorgabe rounded-lg, und 44 px am Handy / 40 px am
// Schreibtisch statt 32 px – so verlangt es die Gestaltungsvorgabe.
<Button asChild className={cn("h-11 gap-2 rounded-pille px-4 text-sm font-semibold md:h-10", className)}>
<Link href="/scannen">
<ScanLine className="size-[18px]" strokeWidth={2} aria-hidden="true" />
Scannen
</Link>
<Button asChild className={gemeinsam}>
<Link href="/scannen">{inhalt}</Link>
</Button>
);
}
+66
View File
@@ -0,0 +1,66 @@
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
/**
* Hilfetexte als Markdown – ohne rohes HTML.
*
* `react-markdown` laesst rohes HTML von sich aus fallen; `rehype-raw` wuerde es
* wieder einschalten und ist deshalb bewusst nicht installiert. Ein Redakteur
* darf Hilfetexte schreiben, aber kein Skript in die Abarbeitungsansicht
* bringen, die jeder Techniker oeffnet.
*
* Links oeffnen in einem neuen Tab mit `rel="noreferrer"`, damit der Lauf beim
* Klick nicht verlassen wird und die Zielseite nichts ueber die Herkunft erfaehrt.
*/
export function Markdown({ text, className }: { text: string; className?: string }) {
return (
<div className={`markdown text-sm text-nebentext ${className ?? ""}`}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
/*
* `react-markdown` leert den `href`, wenn das Protokoll nicht erlaubt
* ist. Ein `<a href="">` laedt beim Klick die aktuelle Seite in einem
* neuen Tab — verwirrender als gar kein Verweis. Der Text bleibt
* stehen, nur das Anklickbare faellt weg.
*/
a: ({ href, children }) =>
href ? (
<a
href={href}
target="_blank"
rel="noreferrer"
className="text-akzent underline underline-offset-2"
>
{children}
</a>
) : (
<span className="text-nebentext">{children}</span>
),
/*
* Bilder werden **nicht** gerendert, sondern als Alternativtext
* gezeigt. Ein Bild in einem Hilfetext laege auf einem fremden Host,
* und `img-src` erlaubt nur `self`, `data:` und `blob:` — es wuerde
* also ohnehin nie erscheinen, nur ein kaputtes Symbol hinterlassen.
* Anhaenge sind fuer Phase 2 vorgesehen; bis dahin ist der Text
* ehrlicher als ein leerer Rahmen.
*/
img: ({ alt }) => <span className="text-nebentext">[Bild{alt ? `: ${alt}` : ""}]</span>,
code: ({ children }) => (
<code className="mono rounded bg-flaeche-hoch px-1 py-0.5 text-[0.9em]">{children}</code>
),
pre: ({ children }) => (
<pre className="mono overflow-x-auto rounded-karte border border-linie bg-grund p-3 text-xs">
{children}
</pre>
),
ul: ({ children }) => <ul className="list-disc pl-5">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal pl-5">{children}</ol>,
p: ({ children }) => <p className="[&:not(:first-child)]:mt-2">{children}</p>,
}}
>
{text}
</ReactMarkdown>
</div>
);
}
+170
View File
@@ -0,0 +1,170 @@
"use client";
import { useActionState, useState } from "react";
import { Copy, Download, Trash2 } from "lucide-react";
import {
entwurfVerwerfenAktion,
vorlageArchivierenAktion,
vorlageDuplizierenAktion,
vorlageExportierenAktion,
type AktionsErgebnis,
} from "@/server/actions/vorlagen";
// `w-fit`, damit ein Knopf in einem Block-Element nicht auf volle Breite geht –
// drei verschieden breite Balken untereinander sehen aus wie ein Fehler.
const KNOPF =
"flex w-fit min-h-11 items-center gap-2 rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10";
const FELD =
"min-h-11 rounded-karte border border-linie bg-grund px-3 text-sm text-text outline-none focus-visible:border-akzent";
function Meldung({ ergebnis }: { ergebnis: AktionsErgebnis }) {
if (ergebnis.fehler) {
return (
<p role="alert" className="mt-2 max-w-prose text-sm text-fehler">
{ergebnis.fehler}
</p>
);
}
if (ergebnis.erfolg) {
return (
<p role="status" className="mt-2 max-w-prose text-sm text-erledigt">
{ergebnis.erfolg}
</p>
);
}
return null;
}
/**
* Entwurf verwerfen.
*
* Mit Rueckfrage, weil ein Entwurf nicht protokolliert wird: was hier
* verschwindet, ist wirklich weg. Die veroeffentlichte Version bleibt
* unberuehrt — genau das ist der Sinn des Entwurfs.
*/
export function EntwurfVerwerfen({ versionId }: { versionId: string }) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(entwurfVerwerfenAktion, {});
const [sicher, setSicher] = useState(false);
if (!sicher) {
return (
<button type="button" onClick={() => setSicher(true)} className={KNOPF}>
<Trash2 className="size-4" strokeWidth={1.75} aria-hidden="true" />
Entwurf verwerfen
</button>
);
}
return (
<div className="flex flex-col gap-2">
<p className="max-w-prose text-sm text-faellig">
Der Entwurf wird gelöscht und ist nicht wiederherstellbar. Die veröffentlichte Version bleibt.
</p>
<div className="flex flex-wrap gap-2">
<form action={aktion}>
<input type="hidden" name="versionId" value={versionId} />
<button
type="submit"
disabled={laeuft}
className="flex min-h-11 items-center rounded-pille border border-fehler px-4 text-sm text-fehler hover:bg-fehler/10 md:min-h-10"
>
{laeuft ? "Wird verworfen …" : "Ja, verwerfen"}
</button>
</form>
<button type="button" onClick={() => setSicher(false)} className={KNOPF}>
Abbrechen
</button>
</div>
<Meldung ergebnis={ergebnis} />
</div>
);
}
/** Duplizieren: neue Vorlage, Inhalt der neuesten Version als Entwurf. */
export function VorlageDuplizieren({ vorlageId, name }: { vorlageId: string; name: string }) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(vorlageDuplizierenAktion, {});
return (
<details>
<summary className={`${KNOPF} cursor-pointer list-none`}>
<Copy className="size-4" strokeWidth={1.75} aria-hidden="true" />
Duplizieren
</summary>
<form action={aktion} className="mt-3 flex flex-col gap-3 sm:flex-row sm:items-end">
<input type="hidden" name="vorlageId" value={vorlageId} />
<label className="flex min-w-0 flex-1 flex-col gap-1.5 text-sm">
<span className="text-nebentext">Name der Kopie</span>
<input name="name" required defaultValue={`${name} (Kopie)`} className={FELD} />
</label>
<button type="submit" disabled={laeuft} className={KNOPF}>
{laeuft ? "Wird kopiert …" : "Kopie anlegen"}
</button>
</form>
<Meldung ergebnis={ergebnis} />
</details>
);
}
/** Archivieren statt loeschen – sonst verloere das Archiv seine Bezuege. */
export function VorlageArchivieren({ vorlageId, archiviert }: { vorlageId: string; archiviert: boolean }) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(vorlageArchivierenAktion, {});
return (
<div>
<form action={aktion}>
<input type="hidden" name="vorlageId" value={vorlageId} />
<input type="hidden" name="archiviert" value={archiviert ? "false" : "true"} />
<button type="submit" disabled={laeuft} className={KNOPF}>
{archiviert ? "wieder freigeben" : "archivieren"}
</button>
</form>
<Meldung ergebnis={ergebnis} />
</div>
);
}
/**
* Export einer Version als Datei.
*
* Die Server Action gibt den Text zurueck, und der Browser legt daraus die
* Datei an. Ein Route Handler dafuer waere ein zweiter Weg zu denselben Daten,
* mit eigener Rechtepruefung — und damit eine zweite Stelle, die falsch sein kann.
*/
export function VersionExportieren({ versionId }: { versionId: string }) {
const [laeuft, setLaeuft] = useState(false);
const [fehler, setFehler] = useState<string | null>(null);
async function exportieren() {
setLaeuft(true);
setFehler(null);
try {
const ergebnis = await vorlageExportierenAktion(versionId);
if (!("inhalt" in ergebnis)) {
setFehler(ergebnis.fehler ?? "Der Export ist fehlgeschlagen.");
return;
}
const adresse = URL.createObjectURL(new Blob([ergebnis.inhalt], { type: "application/json" }));
const verweis = document.createElement("a");
verweis.href = adresse;
verweis.download = ergebnis.dateiname;
verweis.click();
URL.revokeObjectURL(adresse);
} finally {
setLaeuft(false);
}
}
return (
<div>
<button type="button" onClick={() => void exportieren()} disabled={laeuft} className={KNOPF}>
<Download className="size-4" strokeWidth={1.75} aria-hidden="true" />
{laeuft ? "Wird erzeugt …" : "Als JSON exportieren"}
</button>
{fehler ? (
<p role="alert" className="mt-2 max-w-prose text-sm text-fehler">
{fehler}
</p>
) : null}
</div>
);
}
+78
View File
@@ -0,0 +1,78 @@
"use client";
import { useActionState } from "react";
import { Button } from "@/components/ui/button";
import { vorlageAnlegenAktion, type AktionsErgebnis } from "@/server/actions/vorlagen";
const FELD =
"min-h-11 rounded-karte border border-linie bg-grund px-3 text-sm text-text outline-none focus-visible:border-akzent";
/**
* Nur die Kopfdaten. Abschnitte, Schritte und Variablen entstehen danach im
* Editor — ein Formular, das alles auf einmal wollte, waere eine Wand aus
* Feldern, bevor ueberhaupt etwas existiert.
*/
export function VorlageAnlegen({ kunden }: { kunden: readonly { id: string; name: string }[] }) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(vorlageAnlegenAktion, {});
return (
<form action={aktion} className="flex flex-col gap-4">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Name</span>
<input name="name" required className={FELD} placeholder="PC-Neuinstallation" />
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Beschreibung (optional)</span>
<textarea name="beschreibung" rows={2} className={`${FELD} py-2`} />
</label>
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Kategorie (optional)</span>
<input name="kategorie" className={FELD} placeholder="Client" />
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Schlagworte, mit Komma getrennt</span>
<input name="schlagworte" className={FELD} placeholder="windows, onboarding" />
</label>
</div>
{/*
* Die Kundenwahl erscheint nur im Modus Dienstleistung – im Modus Intern
* gibt es keine, und der Server verwirft eine mitgeschickte Kennung
* ohnehin (#008).
*/}
{kunden.length > 0 ? (
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Nur für einen Kunden (optional)</span>
<select name="kundeId" defaultValue="" className={FELD}>
<option value="">für alle Kunden</option>
{kunden.map((kunde) => (
<option key={kunde.id} value={kunde.id}>
{kunde.name}
</option>
))}
</select>
</label>
) : null}
{ergebnis.fehler ? (
<p role="alert" className="text-sm text-fehler">
{ergebnis.fehler}
</p>
) : null}
{ergebnis.erfolg ? (
<p role="status" className="text-sm text-erledigt">
{ergebnis.erfolg}
</p>
) : null}
<Button
type="submit"
disabled={laeuft}
className="h-11 self-start rounded-pille px-5 text-sm font-semibold"
>
{laeuft ? "Wird angelegt …" : "Vorlage anlegen"}
</Button>
</form>
);
}
@@ -0,0 +1,63 @@
"use client";
import { useActionState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Pencil } from "lucide-react";
import { Button } from "@/components/ui/button";
import { entwurfOeffnenAktion, type AktionsErgebnis } from "@/server/actions/vorlagen";
/**
* „Bearbeiten“ – der wichtigste Knopf der Vorlagenseite.
*
* Er legt keinen Text um, sondern erzeugt eine **tiefe Kopie** der letzten
* veroeffentlichten Version als neuen Entwurf (#009) und fuehrt dann in den
* Editor. Gibt es schon einen Entwurf, bekommt man genau diesen — auch wenn zwei
* Redakteure gleichzeitig klicken.
*
* Als Client-Komponente, weil die Server Action die Kennung des Entwurfs
* zurueckgibt und erst danach klar ist, wohin es geht.
*/
export function VorlageBearbeitenKnopf({
vorlageId,
hatEntwurf,
}: {
vorlageId: string;
hatEntwurf: boolean;
}) {
const router = useRouter();
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis & { versionId?: string }, FormData>(
entwurfOeffnenAktion,
{},
);
useEffect(() => {
if (ergebnis.versionId) {
router.push(`/vorlagen/version/${ergebnis.versionId}`);
}
}, [ergebnis.versionId, router]);
return (
<div>
<form action={aktion}>
<input type="hidden" name="vorlageId" value={vorlageId} />
<Button
type="submit"
disabled={laeuft || ergebnis.versionId !== undefined}
className="h-11 gap-2 rounded-pille px-5 text-sm font-semibold md:h-10"
>
<Pencil className="size-4" strokeWidth={2} aria-hidden="true" />
{laeuft || ergebnis.versionId
? "Editor wird geöffnet …"
: hatEntwurf
? "Entwurf weiter bearbeiten"
: "Bearbeiten"}
</Button>
</form>
{ergebnis.fehler ? (
<p role="alert" className="mt-2 max-w-prose text-sm text-fehler">
{ergebnis.fehler}
</p>
) : null}
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
"use client";
import { useActionState, useState } from "react";
import { Button } from "@/components/ui/button";
import { vorlageImportierenAktion, type AktionsErgebnis } from "@/server/actions/vorlagen";
/**
* Einspielen eines Austauschdokuments.
*
* Die Datei wird **im Browser** gelesen und ihr Inhalt als Text mitgeschickt,
* statt sie als Datei-Upload durch die Server Action zu schleusen. Das Format
* ist Text, die Datei ist klein, und so bleibt die Action dieselbe wie fuer
* eingefuegten Inhalt — ein Weg statt zwei.
*
* Eingespielt wird immer eine **neue** Vorlage als Entwurf, nie in eine
* bestehende hinein (docs/vorlagen-format.md).
*/
export function VorlageImportieren() {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(vorlageImportierenAktion, {});
const [inhalt, setInhalt] = useState("");
const [leseFehler, setLeseFehler] = useState<string | null>(null);
async function dateiLesen(datei: File | undefined) {
if (!datei) return;
setLeseFehler(null);
try {
setInhalt(await datei.text());
} catch {
setLeseFehler("Die Datei ließ sich nicht lesen.");
}
}
return (
<form action={aktion} className="flex flex-col gap-4">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Vorlagendatei (.json)</span>
<input
type="file"
accept="application/json,.json"
onChange={(ereignis) => void dateiLesen(ereignis.target.files?.[0])}
className="min-h-11 rounded-karte border border-linie bg-grund px-3 py-2 text-sm text-nebentext file:mr-3 file:rounded-pille file:border-0 file:bg-flaeche-hoch file:px-3 file:py-1.5 file:text-sm file:text-text"
/>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">… oder Inhalt einfügen</span>
<textarea
name="dokument"
rows={6}
required
value={inhalt}
onChange={(ereignis) => setInhalt(ereignis.target.value)}
className="mono rounded-karte border border-linie bg-grund px-3 py-2 text-xs text-text outline-none focus-visible:border-akzent"
placeholder={'{ "format": 1, "name": "…", "struktur": { … } }'}
/>
</label>
{leseFehler ? (
<p role="alert" className="text-sm text-fehler">
{leseFehler}
</p>
) : null}
{ergebnis.fehler ? (
<p role="alert" className="text-sm text-fehler">
{ergebnis.fehler}
</p>
) : null}
{ergebnis.erfolg ? (
<p role="status" className="text-sm text-erledigt">
{ergebnis.erfolg}
</p>
) : null}
<Button
type="submit"
disabled={laeuft}
className="h-11 self-start rounded-pille px-5 text-sm font-semibold"
>
{laeuft ? "Wird eingespielt …" : "Einspielen"}
</Button>
</form>
);
}
+94
View File
@@ -0,0 +1,94 @@
"use client";
import { useActionState } from "react";
import { Button } from "@/components/ui/button";
import { vorlageBearbeitenAktion, type AktionsErgebnis } from "@/server/actions/vorlagen";
const FELD =
"min-h-11 rounded-karte border border-linie bg-grund px-3 text-sm text-text outline-none focus-visible:border-akzent";
/**
* Kopfdaten einer Vorlage: Name, Beschreibung, Kategorie, Schlagworte, Kunde.
*
* Sie haengen an der Vorlage, nicht an einer Version — deshalb lassen sie sich
* jederzeit aendern, auch wenn die neueste Version veroeffentlicht ist. Ein
* Tippfehler im Namen soll keine neue Version kosten, und die Schritte, auf die
* ein Lauf verweist, bleiben davon unberuehrt.
*/
export function VorlageKopfdaten({
vorlage,
kunden,
}: {
vorlage: {
readonly id: string;
readonly name: string;
readonly beschreibung: string | null;
readonly kategorie: string | null;
readonly schlagworte: readonly string[];
readonly kundeId: string | null;
};
kunden: readonly { id: string; name: string }[];
}) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(vorlageBearbeitenAktion, {});
return (
<form action={aktion} className="flex flex-col gap-4">
<input type="hidden" name="vorlageId" value={vorlage.id} />
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Name</span>
<input name="name" required defaultValue={vorlage.name} className={FELD} />
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Beschreibung</span>
<textarea
name="beschreibung"
rows={2}
defaultValue={vorlage.beschreibung ?? ""}
className={`${FELD} py-2`}
/>
</label>
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Kategorie</span>
<input name="kategorie" defaultValue={vorlage.kategorie ?? ""} className={FELD} />
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Schlagworte, mit Komma getrennt</span>
<input name="schlagworte" defaultValue={vorlage.schlagworte.join(", ")} className={FELD} />
</label>
</div>
{kunden.length > 0 ? (
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Nur für einen Kunden</span>
<select name="kundeId" defaultValue={vorlage.kundeId ?? ""} className={FELD}>
<option value="">für alle Kunden</option>
{kunden.map((kunde) => (
<option key={kunde.id} value={kunde.id}>
{kunde.name}
</option>
))}
</select>
</label>
) : null}
{ergebnis.fehler ? (
<p role="alert" className="text-sm text-fehler">
{ergebnis.fehler}
</p>
) : null}
{ergebnis.erfolg ? (
<p role="status" className="text-sm text-erledigt">
{ergebnis.erfolg}
</p>
) : null}
<Button
type="submit"
disabled={laeuft}
className="h-11 self-start rounded-pille px-5 text-sm font-semibold"
>
{laeuft ? "Wird gespeichert …" : "Kopfdaten speichern"}
</Button>
</form>
);
}
@@ -0,0 +1,149 @@
"use client";
import { useState } from "react";
import { KlappKarte, LoeschKnopf, TextKnopf, Textfeld } from "@/components/vorlagen/editor-felder";
import { SortierBereich, SortierZeile, VerschiebeKnoepfe } from "@/components/vorlagen/editor-sortieren";
import { SchrittKarte } from "@/components/vorlagen/editor-schritt";
import { abschnittNummer, type EditorAbschnitt, type EditorSchritt } from "@/lib/editor-aufbau";
import { GRENZEN } from "@/lib/vorlagen-format";
/**
* Ein Abschnitt mit seinen Schritten (#010).
*
* Die Nummer links steht fuer die Position und wird nie gespeichert — wer den
* Abschnitt verschiebt, bekommt sofort die neue Nummer zu sehen.
*
* Aufgeklappt als Standard, anders als Schritte und Variablen: man arbeitet in
* aller Regel in einem Abschnitt und will seine Schritte sehen. Zugeklappt steht
* die Zahl der Schritte daneben, damit beim Umsortieren klar ist, was da
* gerade wandert.
*
* Schritte werden innerhalb ihres Abschnitts sortiert; jeder Abschnitt hat dazu
* seinen eigenen Sortierbereich. Ein Schritt laesst sich damit nicht in einen
* anderen Abschnitt ziehen — das braucht die Vorlagenpflege nicht, und ein
* Bereich ueber alle Abschnitte hinweg haette am Handy Ziehwege von mehreren
* Bildschirmhoehen.
*/
export function AbschnittKarte({
abschnitt,
index,
anzahl,
lesend,
zuletztAngelegt,
anfasser,
aendern,
loeschen,
aufSortiert,
schrittAendern,
schrittHinzufuegen,
schrittLoeschen,
schritteUmsortieren,
}: {
abschnitt: EditorAbschnitt;
index: number;
anzahl: number;
lesend: boolean;
/** Kennung der zuletzt angelegten Zeile — die wird aufgeklappt gezeigt. */
zuletztAngelegt: string | null;
anfasser: React.ReactNode;
aendern: (teil: Partial<EditorAbschnitt>) => void;
loeschen: () => void;
aufSortiert: (von: number, nach: number) => void;
schrittAendern: (kennung: string, teil: Partial<EditorSchritt>) => void;
schrittHinzufuegen: () => void;
schrittLoeschen: (kennung: string) => void;
schritteUmsortieren: (von: number, nach: number) => void;
}) {
const [offen, setzeOffen] = useState(true);
const voll = abschnitt.schritte.length >= GRENZEN.schritteJeAbschnitt;
const anzahlSchritte = abschnitt.schritte.length;
return (
<KlappKarte
offen={offen}
umschalten={() => setzeOffen((vorher) => !vorher)}
flaeche="flaeche"
ueberschrift
anfasser={anfasser}
kopf={
<>
<span className="zahl shrink-0 text-lg text-akzent">{abschnittNummer(index)}</span>
<span className="shrink-0 text-nebentext">·</span>
<span className={`truncate font-semibold ${abschnitt.titel.trim() === "" ? "text-nebentext" : ""}`}>
{abschnitt.titel.trim() === "" ? "ohne Titel" : abschnitt.titel}
</span>
<span className="shrink-0 text-xs font-normal text-nebentext">
{anzahlSchritte} {anzahlSchritte === 1 ? "Schritt" : "Schritte"}
</span>
</>
}
knoepfe={
lesend ? null : (
<>
<VerschiebeKnoepfe art="Abschnitt" index={index} anzahl={anzahl} aufSortiert={aufSortiert} />
<LoeschKnopf
beschriftung="Abschnitt löschen"
{...(anzahlSchritte > 0
? {
frage: `Abschnitt mit ${anzahlSchritte} ${
anzahlSchritte === 1 ? "Schritt" : "Schritten"
} löschen?`,
}
: {})}
aufLoeschen={loeschen}
/>
</>
)
}
>
{lesend ? null : (
<Textfeld
beschriftung="Titel des Abschnitts"
wert={abschnitt.titel}
setzen={(titel) => aendern({ titel })}
lesend={lesend}
vorschlag="Vorbereitung"
/>
)}
{anzahlSchritte === 0 ? (
<p className="text-sm text-nebentext">Noch kein Schritt in diesem Abschnitt.</p>
) : (
<SortierBereich
kennungen={abschnitt.schritte.map((schritt) => schritt.kennung)}
art="Schritt"
lesend={lesend}
aufSortiert={schritteUmsortieren}
>
<ul className="flex flex-col gap-3">
{abschnitt.schritte.map((schritt, stelle) => (
<SortierZeile key={schritt.kennung} kennung={schritt.kennung} lesend={lesend}>
{(schrittAnfasser) => (
<SchrittKarte
schritt={schritt}
index={stelle}
anzahl={anzahlSchritte}
lesend={lesend}
neu={schritt.kennung === zuletztAngelegt}
anfasser={schrittAnfasser}
aendern={(teil) => schrittAendern(schritt.kennung, teil)}
loeschen={() => schrittLoeschen(schritt.kennung)}
aufSortiert={schritteUmsortieren}
/>
)}
</SortierZeile>
))}
</ul>
</SortierBereich>
)}
{lesend ? null : (
<TextKnopf
beschriftung={voll ? `Höchstens ${GRENZEN.schritteJeAbschnitt} Schritte` : "Schritt hinzufügen"}
aus={voll}
aufKlick={schrittHinzufuegen}
/>
)}
</KlappKarte>
);
}
+460
View File
@@ -0,0 +1,460 @@
"use client";
import { useId, useState } from "react";
import { ChevronDown, Plus, Trash2, type LucideIcon } from "lucide-react";
import { GRENZEN } from "@/lib/vorlagen-format";
/**
* Die kleinen Bauteile des Vorlagen-Editors (#010).
*
* Jedes traegt einen `lesend`-Schalter statt einer zweiten, nur lesenden
* Fassung: eine veroeffentlichte Version wird mit denselben Bauteilen gezeigt
* wie ein Entwurf. Zwei Codepfade waeren zwei Gelegenheiten, dass die Ansicht
* etwas anderes zeigt als der Editor.
*
* Eingabefelder werden im Lesemodus `readOnly` und nicht `disabled`: der Inhalt
* bleibt markierbar, und ein Befehlstext laesst sich herauskopieren. Auswahl und
* Kontrollkasten kennen kein `readOnly`, die werden `disabled`.
*/
export const FELD =
"min-h-11 w-full rounded-karte border border-linie bg-grund px-3 text-sm text-text outline-none focus-visible:border-akzent read-only:text-nebentext";
const FLAECHE_FELD = `${FELD} py-2`;
/** Knopf mit Symbol, 44 px am Handy. Beschriftung nur fuer Hilfsmittel. */
export function SymbolKnopf({
symbol: Symbol,
beschriftung,
aufKlick,
aus,
ton,
}: {
symbol: LucideIcon;
beschriftung: string;
aufKlick: () => void;
aus?: boolean;
ton?: "fehler";
}) {
return (
<button
type="button"
onClick={aufKlick}
disabled={aus}
title={beschriftung}
aria-label={beschriftung}
className={`flex size-11 shrink-0 items-center justify-center rounded-pille border border-linie md:size-10 ${
ton === "fehler"
? "text-nebentext hover:bg-flaeche-hoch hover:text-fehler"
: "text-nebentext hover:bg-flaeche-hoch hover:text-text"
} disabled:pointer-events-none disabled:opacity-40`}
>
<Symbol className="size-[18px]" strokeWidth={1.75} aria-hidden="true" />
</button>
);
}
/** Flacher Knopf mit Text, fuer „hinzufügen“. */
export function TextKnopf({
beschriftung,
aufKlick,
aus,
symbol: Symbol = Plus,
}: {
beschriftung: string;
aufKlick: () => void;
aus?: boolean;
symbol?: LucideIcon;
}) {
return (
<button
type="button"
onClick={aufKlick}
disabled={aus}
className="flex min-h-11 items-center gap-2 self-start rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text disabled:pointer-events-none disabled:opacity-40 md:min-h-10"
>
<Symbol className="size-[18px]" strokeWidth={1.75} aria-hidden="true" />
{beschriftung}
</button>
);
}
/**
* Loeschen mit Rueckfrage an der Stelle, an der geklickt wurde.
*
* Die Rueckfrage nur dort, wo mehr als eine Zeile verloren geht — einen leeren
* Abschnitt oder eine Option legt man in zwei Sekunden neu an, und eine
* Rueckfrage, die man immer wegklickt, wird nicht gelesen.
*/
export function LoeschKnopf({
beschriftung,
frage,
aufLoeschen,
}: {
beschriftung: string;
frage?: string;
aufLoeschen: () => void;
}) {
const [fragt, setzeFragt] = useState(false);
if (frage && fragt) {
return (
<div className="flex flex-wrap items-center justify-end gap-2">
<span className="text-xs text-faellig">{frage}</span>
<button
type="button"
onClick={aufLoeschen}
className="min-h-11 rounded-pille border border-linie px-3 text-sm text-fehler hover:bg-flaeche-hoch md:min-h-10"
>
Löschen
</button>
<button
type="button"
onClick={() => setzeFragt(false)}
className="min-h-11 rounded-pille px-3 text-sm text-nebentext hover:text-text md:min-h-10"
>
Abbrechen
</button>
</div>
);
}
return (
<SymbolKnopf
symbol={Trash2}
beschriftung={beschriftung}
ton="fehler"
aufKlick={() => (frage ? setzeFragt(true) : aufLoeschen())}
/>
);
}
export function Textfeld({
beschriftung,
wert,
setzen,
lesend,
hinweis,
fehler,
mono,
vorschlag,
maxLaenge = GRENZEN.text,
versteckteBeschriftung,
}: {
beschriftung: string;
wert: string;
setzen: (wert: string) => void;
lesend: boolean;
hinweis?: string;
fehler?: string;
mono?: boolean;
vorschlag?: string;
maxLaenge?: number;
/** Fuer Felder, deren Bedeutung aus dem Zusammenhang klar ist — etwa der Titel in einer Zeile. */
versteckteBeschriftung?: boolean;
}) {
return (
<label className="flex min-w-0 flex-1 flex-col gap-1.5 text-sm">
<span className={versteckteBeschriftung ? "sr-only" : "text-nebentext"}>{beschriftung}</span>
<input
value={wert}
onChange={(ereignis) => setzen(ereignis.target.value)}
readOnly={lesend}
maxLength={maxLaenge}
// Kein Vorschlagstext im Lesemodus: ein grauer Beispielwert in einem
// Feld, das niemand fuellen kann, sieht aus wie ein Inhalt.
placeholder={lesend ? undefined : vorschlag}
aria-invalid={fehler ? true : undefined}
className={`${FELD} ${mono ? "mono" : ""} ${fehler ? "border-fehler" : ""}`}
/>
{fehler ? (
<span role="alert" className="text-xs text-fehler">
{fehler}
</span>
) : hinweis ? (
<span className="text-xs text-nebentext">{hinweis}</span>
) : null}
</label>
);
}
export function Flaechenfeld({
beschriftung,
wert,
setzen,
lesend,
hinweis,
zeilen = 3,
mono,
vorschlag,
}: {
beschriftung: string;
wert: string;
setzen: (wert: string) => void;
lesend: boolean;
hinweis?: string;
zeilen?: number;
mono?: boolean;
vorschlag?: string;
}) {
return (
<label className="flex min-w-0 flex-col gap-1.5 text-sm">
<span className="text-nebentext">{beschriftung}</span>
<textarea
value={wert}
onChange={(ereignis) => setzen(ereignis.target.value)}
readOnly={lesend}
rows={zeilen}
maxLength={GRENZEN.text}
placeholder={lesend ? undefined : vorschlag}
className={`${FLAECHE_FELD} ${mono ? "mono" : ""}`}
/>
{hinweis ? <span className="text-xs text-nebentext">{hinweis}</span> : null}
</label>
);
}
export function Auswahlfeld<T extends string>({
beschriftung,
wert,
werte,
setzen,
lesend,
beschriftungen,
}: {
beschriftung: string;
wert: T;
werte: readonly T[];
setzen: (wert: T) => void;
lesend: boolean;
beschriftungen?: Readonly<Record<string, string>>;
}) {
return (
<label className="flex min-w-0 flex-col gap-1.5 text-sm">
<span className="text-nebentext">{beschriftung}</span>
<select
value={wert}
onChange={(ereignis) => setzen(ereignis.target.value as T)}
disabled={lesend}
className={`${FELD} disabled:text-nebentext`}
>
{werte.map((eintrag) => (
<option key={eintrag} value={eintrag}>
{beschriftungen?.[eintrag] ?? eintrag}
</option>
))}
</select>
</label>
);
}
export function Kontrollkasten({
beschriftung,
wert,
setzen,
lesend,
}: {
beschriftung: string;
wert: boolean;
setzen: (wert: boolean) => void;
lesend: boolean;
}) {
return (
<label className="flex min-h-11 shrink-0 cursor-pointer items-center gap-2 text-sm md:min-h-10">
<input
type="checkbox"
checked={wert}
onChange={(ereignis) => setzen(ereignis.target.checked)}
disabled={lesend}
className="size-5 accent-akzent"
/>
<span className="text-nebentext">{beschriftung}</span>
</label>
);
}
/** Zahlfeld fuer `min` und `max`: leeres Feld heisst „keine Grenze“, nicht „null“. */
export function Zahlfeld({
beschriftung,
wert,
setzen,
lesend,
}: {
beschriftung: string;
wert: number | undefined;
setzen: (wert: number | undefined) => void;
lesend: boolean;
}) {
return (
<label className="flex min-w-0 flex-col gap-1.5 text-sm">
<span className="text-nebentext">{beschriftung}</span>
<input
type="number"
value={wert ?? ""}
onChange={(ereignis) => {
const roh = ereignis.target.value;
const zahl = Number(roh);
setzen(roh === "" || !Number.isFinite(zahl) ? undefined : zahl);
}}
readOnly={lesend}
className={FELD}
/>
</label>
);
}
/**
* Die Optionen einer Auswahl — bei einem Schritt wie bei einer Variablen.
*
* Die Obergrenze steht am Knopf, bevor jemand dagegen laeuft; das Schema
* beanstandet sie sonst erst beim Speichern.
*/
export function OptionenFelder({
optionen,
setzen,
lesend,
}: {
optionen: readonly string[];
setzen: (optionen: readonly string[]) => void;
lesend: boolean;
}) {
const voll = optionen.length >= GRENZEN.optionen;
return (
<div className="flex flex-col gap-2">
<span className="text-sm text-nebentext">Optionen</span>
{optionen.length === 0 ? (
<p className="text-xs text-faellig">
Ohne Option bleibt die Auswahl leer. Bitte mindestens eine angeben.
</p>
) : null}
<ul className="flex flex-col gap-2">
{optionen.map((option, index) => (
// Der Index als Schluessel ist hier richtig: die Optionen sind reine
// Zeichenketten ohne eigene Kennung, und geloescht wird immer die Zeile,
// auf die geklickt wurde.
<li key={index} className="flex items-center gap-2">
<input
value={option}
onChange={(ereignis) =>
setzen(optionen.map((alt, stelle) => (stelle === index ? ereignis.target.value : alt)))
}
readOnly={lesend}
maxLength={GRENZEN.text}
aria-label={`Option ${index + 1}`}
className={FELD}
/>
{lesend ? null : (
<LoeschKnopf
beschriftung={`Option ${index + 1} löschen`}
aufLoeschen={() => setzen(optionen.filter((_, stelle) => stelle !== index))}
/>
)}
</li>
))}
</ul>
{lesend ? null : (
<TextKnopf
beschriftung={voll ? `Höchstens ${GRENZEN.optionen} Optionen` : "Option hinzufügen"}
aus={voll}
aufKlick={() => setzen([...optionen, ""])}
/>
)}
</div>
);
}
/** Kleine Pille fuer Status, Typ und Kennzeichen. */
export function Kennzeichen({
children,
ton = "still",
}: {
children: React.ReactNode;
ton?: "still" | "akzent" | "faellig";
}) {
const toene = {
still: "bg-flaeche-hoch text-nebentext",
akzent: "bg-akzent-flaeche text-akzent",
faellig: "border border-linie text-faellig",
} as const;
return <span className={`shrink-0 rounded-pille px-2.5 py-1 text-xs ${toene[ton]}`}>{children}</span>;
}
/**
* Eine Karte, die sich zuklappen laesst (#010).
*
* Abschnitte, Schritte und Variablen sehen gleich aus: eine Kopfzeile, die immer
* sichtbar bleibt — Anfasser, Zusammenfassung, Knoepfe —, und darunter die
* Felder. Ohne das Zuklappen war eine einzige Beispielvorlage rund 17 000 px
* hoch; mit den sieben Vorlagen aus #017 waere der Editor nicht mehr bedienbar.
*
* Der Inhalt wird **ausgeblendet, nicht ausgehaengt**: die Felder bleiben im
* Baum und damit im React-Zustand. Wuerde man sie entfernen, verloere das
* Zuklappen alles, was seit dem letzten Speichern getippt wurde.
*
* Kein `<details>`: dessen `<summary>` schaltet bei jedem Klick um — auch beim
* Klick auf den Anfasser oder den Loeschknopf, die in der Kopfzeile stehen
* muessen, weil man auch zugeklappt sortieren und loeschen koennen soll.
*/
export function KlappKarte({
offen,
umschalten,
anfasser,
knoepfe,
kopf,
ueberschrift,
flaeche = "flaeche-hoch",
children,
}: {
offen: boolean;
umschalten: () => void;
anfasser: React.ReactNode;
knoepfe?: React.ReactNode;
/** Was zugeklappt zu sehen ist. Traegt selbst `truncate`, wo es lang werden kann. */
kopf: React.ReactNode;
/** Abschnitte sind Ueberschriften; Schritte und Variablen nicht. */
ueberschrift?: boolean;
flaeche?: "flaeche" | "flaeche-hoch";
children: React.ReactNode;
}) {
const inhaltKennung = useId();
const schalter = (
<button
type="button"
onClick={umschalten}
aria-expanded={offen}
aria-controls={inhaltKennung}
className="flex min-h-11 w-full min-w-40 flex-1 items-center gap-2 text-left text-sm md:min-h-10"
>
<ChevronDown
className={`size-4 shrink-0 text-nebentext ${offen ? "" : "-rotate-90"}`}
strokeWidth={2}
aria-hidden="true"
/>
{kopf}
</button>
);
return (
<div
className={`rounded-karte border border-linie ${
flaeche === "flaeche" ? "bg-flaeche" : "bg-flaeche-hoch"
}`}
>
{/*
Eine Zeile, die umbricht: am Schreibtisch stehen Zusammenfassung und
Knoepfe nebeneinander, bei 390 px rutschen die Knoepfe darunter, sobald
der Text seine 10 rem nicht mehr bekommt.
*/}
<div className="flex flex-wrap items-center gap-2 p-3">
{anfasser}
{ueberschrift ? <h3 className="flex min-w-40 flex-1">{schalter}</h3> : schalter}
{knoepfe ? <div className="ml-auto flex items-center gap-2">{knoepfe}</div> : null}
</div>
<div id={inhaltKennung} className={offen ? "flex flex-col gap-3 px-3 pb-3" : "hidden"}>
{children}
</div>
</div>
);
}
+259
View File
@@ -0,0 +1,259 @@
"use client";
import { useState } from "react";
import {
Auswahlfeld,
Flaechenfeld,
Kennzeichen,
KlappKarte,
Kontrollkasten,
LoeschKnopf,
Textfeld,
OptionenFelder,
Zahlfeld,
} from "@/components/vorlagen/editor-felder";
import { VerschiebeKnoepfe } from "@/components/vorlagen/editor-sortieren";
import {
SCHRITT_TYP_TEXT,
STANDARD_SHELL,
konfigFuerTyp,
type EditorSchritt,
type SchrittTyp,
} from "@/lib/editor-aufbau";
import { SCHRITT_TYPEN, SHELLS, type SchrittKonfig } from "@/lib/vorlagen-format";
/**
* Ein Schritt im Editor (#010).
*
* Bewusst keine Tabelle und keine festen Breiten: bei 390 px stehen Titel,
* Typ und die typabhaengigen Felder untereinander, ab `sm` nebeneinander.
*/
/**
* Einen Schluessel in der `konfig` setzen oder entfernen.
*
* Ein leeres Feld loescht den Schluessel, statt `""` zu speichern: `min` ohne
* Wert heisst „keine Grenze“, und eine leere Einheit ist keine Einheit.
*/
function konfigMit(konfig: SchrittKonfig, schluessel: string, wert: unknown): SchrittKonfig {
const neu: Record<string, unknown> = { ...konfig };
if (wert === undefined || wert === "") delete neu[schluessel];
else neu[schluessel] = wert;
return neu as SchrittKonfig;
}
function KonfigFelder({
typ,
konfig,
setzen,
lesend,
}: {
typ: SchrittTyp;
konfig: SchrittKonfig;
setzen: (konfig: SchrittKonfig) => void;
lesend: boolean;
}) {
switch (typ) {
case "TEXT":
return (
<Kontrollkasten
beschriftung="Mehrzeiliges Feld"
wert={konfig.mehrzeilig === true}
setzen={(wert) => setzen(konfigMit(konfig, "mehrzeilig", wert))}
lesend={lesend}
/>
);
case "ZAHL":
return (
<div className="grid gap-3 sm:grid-cols-3">
<Zahlfeld
beschriftung="Mindestens"
wert={konfig.min}
setzen={(wert) => setzen(konfigMit(konfig, "min", wert))}
lesend={lesend}
/>
<Zahlfeld
beschriftung="Höchstens"
wert={konfig.max}
setzen={(wert) => setzen(konfigMit(konfig, "max", wert))}
lesend={lesend}
/>
<Textfeld
beschriftung="Einheit"
wert={konfig.einheit ?? ""}
setzen={(wert) => setzen(konfigMit(konfig, "einheit", wert))}
lesend={lesend}
maxLaenge={40}
vorschlag="GB"
hinweis="Grenzen sind Hinweise, kein Verbot."
/>
</div>
);
case "AUSWAHL":
return (
<OptionenFelder
optionen={konfig.optionen ?? []}
setzen={(optionen) => setzen(konfigMit(konfig, "optionen", [...optionen]))}
lesend={lesend}
/>
);
case "BEFEHL":
return (
<div className="flex flex-col gap-3">
<div className="sm:max-w-48">
<Auswahlfeld
beschriftung="Shell"
wert={konfig.shell ?? STANDARD_SHELL}
werte={SHELLS}
setzen={(wert) => setzen(konfigMit(konfig, "shell", wert))}
lesend={lesend}
/>
</div>
<Flaechenfeld
beschriftung="Befehl"
wert={konfig.befehl ?? ""}
setzen={(wert) => setzen(konfigMit(konfig, "befehl", wert))}
lesend={lesend}
zeilen={4}
mono
vorschlag={'New-ADUser -Name "{{vorname}} {{nachname}}"'}
hinweis="Platzhalter werden erst beim Abarbeiten ersetzt."
/>
</div>
);
default:
// HAKEN und DATUM haben keine konfig. Dass dort nichts steht, sieht man;
// ein Satz darueber waere Rauschen an jedem zweiten Schritt.
return null;
}
}
export function SchrittKarte({
schritt,
index,
anzahl,
lesend,
neu,
anfasser,
aendern,
loeschen,
aufSortiert,
}: {
schritt: EditorSchritt;
index: number;
anzahl: number;
lesend: boolean;
/** Gerade angelegt: dann aufgeklappt, denn man will ihn ausfuellen. */
neu: boolean;
anfasser: React.ReactNode;
aendern: (teil: Partial<EditorSchritt>) => void;
loeschen: () => void;
aufSortiert: (von: number, nach: number) => void;
}) {
const [offen, setzeOffen] = useState(neu);
/**
* Beim Typwechsel wird die `konfig` aufgeraeumt — sonst wanderte der Befehl
* eines ehemaligen `BEFEHL`-Schritts unsichtbar mit. `BEFEHL` bekommt dabei
* gleich eine Shell, damit gespeichert ist, was das Formular anzeigt.
*/
function typSetzen(typ: SchrittTyp) {
const gesaeubert = konfigFuerTyp(schritt.konfig, typ);
aendern({
typ,
konfig:
typ === "BEFEHL" && gesaeubert.shell === undefined
? konfigMit(gesaeubert, "shell", STANDARD_SHELL)
: gesaeubert,
});
}
const titel = schritt.titel.trim();
return (
<KlappKarte
offen={offen}
umschalten={() => setzeOffen((vorher) => !vorher)}
anfasser={anfasser}
kopf={
<>
<span className="mono shrink-0 text-xs text-nebentext">{index + 1}</span>
{schritt.pflicht ? (
<span className="shrink-0 text-fehler" title="Pflichtschritt">
*
</span>
) : null}
{/* Ohne Titel bliebe die Zeile leer — und der Schritt unauffindbar. */}
<span className={`truncate ${titel === "" ? "text-nebentext" : ""}`}>
{titel === "" ? "ohne Titel" : titel}
</span>
<Kennzeichen>{SCHRITT_TYP_TEXT[schritt.typ]}</Kennzeichen>
</>
}
knoepfe={
lesend ? null : (
<>
<VerschiebeKnoepfe art="Schritt" index={index} anzahl={anzahl} aufSortiert={aufSortiert} />
<LoeschKnopf beschriftung="Schritt löschen" aufLoeschen={loeschen} />
</>
)
}
>
<Textfeld
beschriftung="Titel"
wert={schritt.titel}
setzen={(neuerTitel) => aendern({ titel: neuerTitel })}
lesend={lesend}
vorschlag="Was ist zu tun?"
/>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="sm:max-w-48">
<Auswahlfeld
beschriftung="Typ"
wert={schritt.typ}
werte={SCHRITT_TYPEN}
setzen={typSetzen}
lesend={lesend}
beschriftungen={SCHRITT_TYP_TEXT}
/>
</div>
<div className="sm:mt-6">
<Kontrollkasten
beschriftung="Pflichtschritt"
wert={schritt.pflicht}
setzen={(pflicht) => aendern({ pflicht })}
lesend={lesend}
/>
</div>
</div>
<KonfigFelder
typ={schritt.typ}
konfig={schritt.konfig}
setzen={(konfig) => aendern({ konfig })}
lesend={lesend}
/>
<Flaechenfeld
beschriftung="Hilfetext"
wert={schritt.hilfetext}
setzen={(hilfetext) => aendern({ hilfetext })}
lesend={lesend}
hinweis="Markdown: **fett**, Listen, Links, `Code`. Kein HTML."
/>
<Textfeld
beschriftung="Doku-Link"
wert={schritt.dokuUrl}
setzen={(dokuUrl) => aendern({ dokuUrl })}
lesend={lesend}
vorschlag="https://intern.example/anleitung"
hinweis="Nur http- oder https-Adressen."
/>
</KlappKarte>
);
}
@@ -0,0 +1,210 @@
"use client";
import { useId } from "react";
import {
DndContext,
KeyboardSensor,
MouseSensor,
TouchSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { ChevronDown, ChevronUp, GripVertical } from "lucide-react";
import { SymbolKnopf } from "@/components/vorlagen/editor-felder";
/**
* Reihenfolge aendern — mit dem Finger, der Maus, der Tastatur oder zwei Knoepfen (#010).
*
* Drei Sensoren, weil ein Anfasser allein nicht reicht:
*
* - `TouchSensor` mit Verzoegerung: ohne sie waere jede Wischbewegung, die auf
* dem Anfasser beginnt, ein Verschieben statt eines Bildlaufs. 200 ms Halten
* sind genug, um das eine vom anderen zu unterscheiden.
* - `MouseSensor` mit 4 px Schwelle, damit ein Klick kein Verschieben auslöst.
* Bewusst nicht der `PointerSensor`: der bediente Maus und Finger zugleich und
* liesse sich dann nicht mehr fuer den Finger allein einstellen.
* - `KeyboardSensor` fuer Leertaste und Pfeiltasten.
*
* Dazu die Knoepfe „nach oben“ und „nach unten“ an jeder Zeile: ein Touch-Drag
* auf einem 390-px-Gerät gelingt nicht jedem zuverlässig, und die Reihenfolge
* ist fachlich wichtig genug, um einen zweiten, langsamen Weg zu haben.
*/
/** Kein Import aus `@dnd-kit/utilities` — das Paket ist nur eine Abhaengigkeit der Abhaengigkeit. */
function schiebeStil(
transform: { x: number; y: number } | null,
transition: string | undefined,
): React.CSSProperties | undefined {
if (!transform) return transition ? { transition } : undefined;
return { transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`, transition };
}
export function SortierBereich({
kennungen,
art,
lesend,
aufSortiert,
children,
}: {
kennungen: readonly string[];
/** „Abschnitt“, „Schritt“, „Variable“ — fuer die Ansagen der Hilfsmittel. */
art: string;
lesend: boolean;
aufSortiert: (von: number, nach: number) => void;
children: React.ReactNode;
}) {
/**
* Eine feste Kennung fuer den Sortierbereich.
*
* Ohne sie vergibt `@dnd-kit` die `aria-describedby`-Kennung aus einem
* modulweiten Zaehler. Der steht auf dem Server anders als im Browser — und
* genau das meldet React beim Hydrieren als Unterschied. `useId` zaehlt in
* beiden Durchlaeufen gleich.
*/
const bereichKennung = useId();
const sensoren = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 4 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
// Im Lesemodus gar kein DndContext: was sich nicht aendern darf, soll auch
// keine Ansage machen, dass es sich verschieben liesse.
if (lesend) return children;
function aufEnde(ereignis: DragEndEvent) {
const { active, over } = ereignis;
if (!over || active.id === over.id) return;
const von = kennungen.indexOf(String(active.id));
const nach = kennungen.indexOf(String(over.id));
if (von < 0 || nach < 0) return;
aufSortiert(von, nach);
}
return (
<DndContext
id={bereichKennung}
sensors={sensoren}
collisionDetection={closestCenter}
onDragEnd={aufEnde}
accessibility={{
screenReaderInstructions: {
draggable: `${art} mit der Leertaste aufnehmen, mit den Pfeiltasten verschieben, mit der Leertaste ablegen, mit Escape abbrechen.`,
},
announcements: {
onDragStart: () => `${art} aufgenommen.`,
onDragOver: () => undefined,
onDragEnd: ({ over }) => (over ? `${art} verschoben.` : `${art} unverändert abgelegt.`),
onDragCancel: () => "Verschieben abgebrochen.",
},
}}
>
<SortableContext items={[...kennungen]} strategy={verticalListSortingStrategy}>
{children}
</SortableContext>
</DndContext>
);
}
/**
* Eine Zeile, die sich verschieben laesst.
*
* Der Anfasser kommt als Bauteil zurueck, damit die Zeile selbst entscheidet, wo
* er sitzt. Im Lesemodus eine andere Komponente statt eines abgeschalteten
* `useSortable`: so haengt kein Sortier-Zustand an etwas, das unveraenderlich ist.
*/
export function SortierZeile({
kennung,
lesend,
className,
children,
}: {
kennung: string;
lesend: boolean;
className?: string;
children: (anfasser: React.ReactNode) => React.ReactNode;
}) {
if (lesend) return <li className={className}>{children(null)}</li>;
return (
<BeweglicheZeile kennung={kennung} className={className}>
{children}
</BeweglicheZeile>
);
}
function BeweglicheZeile({
kennung,
className,
children,
}: {
kennung: string;
className?: string;
children: (anfasser: React.ReactNode) => React.ReactNode;
}) {
const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging } =
useSortable({ id: kennung });
const anfasser = (
<button
type="button"
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
title="Verschieben"
aria-label="Verschieben"
// touch-none, damit die Geste auf dem Anfasser nicht zum Bildlauf wird.
className="flex size-11 shrink-0 cursor-grab touch-none items-center justify-center rounded-pille text-nebentext hover:bg-flaeche-hoch hover:text-text md:size-10"
>
<GripVertical className="size-5" strokeWidth={1.75} aria-hidden="true" />
</button>
);
return (
<li
ref={setNodeRef}
style={schiebeStil(transform, transition)}
className={`${className ?? ""} ${isDragging ? "relative z-10 border-akzent opacity-90" : ""}`}
>
{children(anfasser)}
</li>
);
}
/** Der zweite Weg: eine Position nach oben oder nach unten, ohne Ziehen. */
export function VerschiebeKnoepfe({
art,
index,
anzahl,
aufSortiert,
}: {
art: string;
index: number;
anzahl: number;
aufSortiert: (von: number, nach: number) => void;
}) {
return (
<>
<SymbolKnopf
symbol={ChevronUp}
beschriftung={`${art} nach oben`}
aus={index === 0}
aufKlick={() => aufSortiert(index, index - 1)}
/>
<SymbolKnopf
symbol={ChevronDown}
beschriftung={`${art} nach unten`}
aus={index === anzahl - 1}
aufKlick={() => aufSortiert(index, index + 1)}
/>
</>
);
}
@@ -0,0 +1,252 @@
"use client";
import { useState } from "react";
import { TriangleAlert } from "lucide-react";
import {
Auswahlfeld,
Kennzeichen,
KlappKarte,
Kontrollkasten,
LoeschKnopf,
OptionenFelder,
TextKnopf,
Textfeld,
} from "@/components/vorlagen/editor-felder";
import { SortierBereich, SortierZeile, VerschiebeKnoepfe } from "@/components/vorlagen/editor-sortieren";
import {
SCHLUESSEL_REGEL,
VARIABLEN_TYP_TEXT,
doppelteSchluessel,
schluesselFehler,
type EditorVariable,
} from "@/lib/editor-aufbau";
import { unbenutzteVariablen } from "@/lib/platzhalter";
import { GRENZEN, VARIABLEN_TYPEN } from "@/lib/vorlagen-format";
/**
* Die Variablen einer Vorlage (#010).
*
* Sie werden beim Start eines Laufs abgefragt und fuellen die Platzhalter.
* Die Schluesselregel steht einmal ueber der Liste statt unter jedem Feld — und
* am Feld nur dann, wenn der Schluessel gerade dagegen verstoesst. Ein Hinweis,
* der zwanzigmal untereinander steht, wird nicht mehr gelesen.
*/
function VariablenKarte({
variable,
index,
anzahl,
lesend,
neu,
fehler,
unbenutzt,
anfasser,
aendern,
loeschen,
umsortieren,
}: {
variable: EditorVariable;
index: number;
anzahl: number;
lesend: boolean;
neu: boolean;
fehler: string | undefined;
unbenutzt: boolean;
anfasser: React.ReactNode;
aendern: (teil: Partial<EditorVariable>) => void;
loeschen: () => void;
umsortieren: (von: number, nach: number) => void;
}) {
const [offen, setzeOffen] = useState(neu);
const schluessel = variable.schluessel.trim();
return (
<KlappKarte
offen={offen}
umschalten={() => setzeOffen((vorher) => !vorher)}
anfasser={anfasser}
kopf={
<>
<span className="mono shrink-0 text-xs text-nebentext">{index + 1}</span>
{/* Zugeklappt muss ein fehlerhafter Schluessel auffallen, sonst sucht ihn niemand. */}
{fehler ? (
<TriangleAlert
className="size-4 shrink-0 text-fehler"
strokeWidth={1.75}
aria-label="Fehler im Schlüssel"
/>
) : null}
<code className={`mono shrink-0 text-xs ${schluessel === "" ? "text-nebentext" : ""}`}>
{schluessel === "" ? "ohne Schlüssel" : `{{${schluessel}}}`}
</code>
{/*
* Bei 390 px blieben von der Beschriftung zwei Zeichen uebrig („Be…“).
* Der Schluessel ist das, wonach man sucht; die Beschriftung steht
* eine Zeile tiefer, sobald die Karte offen ist.
*/}
<span className="hidden truncate text-nebentext sm:inline">{variable.beschriftung}</span>
<Kennzeichen>{VARIABLEN_TYP_TEXT[variable.typ]}</Kennzeichen>
</>
}
knoepfe={
lesend ? null : (
<>
<VerschiebeKnoepfe art="Variable" index={index} anzahl={anzahl} aufSortiert={umsortieren} />
<LoeschKnopf beschriftung="Variable löschen" aufLoeschen={loeschen} />
</>
)
}
>
<div className="grid gap-3 sm:grid-cols-2">
<Textfeld
beschriftung="Schlüssel"
wert={variable.schluessel}
setzen={(wert) => aendern({ schluessel: wert })}
lesend={lesend}
mono
maxLaenge={40}
vorschlag="vorname"
{...(fehler ? { fehler } : {})}
/>
<Textfeld
beschriftung="Beschriftung"
wert={variable.beschriftung}
setzen={(wert) => aendern({ beschriftung: wert })}
lesend={lesend}
vorschlag="Vorname"
/>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="sm:max-w-48">
<Auswahlfeld
beschriftung="Typ"
wert={variable.typ}
werte={VARIABLEN_TYPEN}
setzen={(typ) => aendern({ typ })}
lesend={lesend}
beschriftungen={VARIABLEN_TYP_TEXT}
/>
</div>
<div className="sm:mt-6">
<Kontrollkasten
beschriftung="Pflichtangabe beim Start"
wert={variable.pflicht}
setzen={(pflicht) => aendern({ pflicht })}
lesend={lesend}
/>
</div>
</div>
{variable.typ === "AUSWAHL" ? (
<OptionenFelder
optionen={variable.optionen}
setzen={(optionen) => aendern({ optionen })}
lesend={lesend}
/>
) : null}
{schluessel !== "" && unbenutzt ? (
<p className="text-xs text-nebentext">
Wird nirgends verwendet. Das ist erlaubt — sie wird trotzdem beim Start abgefragt.
</p>
) : null}
</KlappKarte>
);
}
export function VariablenBereich({
variablen,
verwendet,
lesend,
zuletztAngelegt,
aendern,
hinzufuegen,
loeschen,
umsortieren,
}: {
variablen: readonly EditorVariable[];
/** Die im Aufbau tatsaechlich vorkommenden Platzhalter. */
verwendet: readonly string[];
lesend: boolean;
/** Kennung der zuletzt angelegten Zeile — die wird aufgeklappt gezeigt. */
zuletztAngelegt: string | null;
aendern: (kennung: string, teil: Partial<EditorVariable>) => void;
hinzufuegen: () => void;
loeschen: (kennung: string) => void;
umsortieren: (von: number, nach: number) => void;
}) {
const doppelt = new Set(doppelteSchluessel(variablen));
const unbenutzt = new Set(
unbenutzteVariablen(
verwendet,
variablen.map((variable) => variable.schluessel.trim()).filter((schluessel) => schluessel !== ""),
),
);
const voll = variablen.length >= GRENZEN.variablen;
return (
<section className="flex flex-col gap-3 rounded-karte border border-linie bg-flaeche p-4">
<div>
<h2 className="text-lg font-semibold">Variablen</h2>
<p className="mt-1 max-w-prose text-sm text-nebentext">
Werden beim Start eines Laufs abgefragt und füllen die Platzhalter{" "}
<code className="mono">{"{{schluessel}}"}</code> in Titeln, Hilfetexten und Befehlen.{" "}
{SCHLUESSEL_REGEL}
</p>
</div>
{variablen.length === 0 ? (
<p className="text-sm text-nebentext">
Keine Variablen. Eine Vorlage ohne Variablen ist in Ordnung — die eingebauten Platzhalter stehen
trotzdem zur Verfügung.
</p>
) : (
<SortierBereich
kennungen={variablen.map((variable) => variable.kennung)}
art="Variable"
lesend={lesend}
aufSortiert={umsortieren}
>
<ul className="flex flex-col gap-3">
{variablen.map((variable, index) => {
const schluessel = variable.schluessel.trim();
const fehler = doppelt.has(schluessel)
? `Den Schlüssel „${schluessel}“ gibt es zweimal.`
: schluessel === ""
? undefined
: schluesselFehler(variable.schluessel);
return (
<SortierZeile key={variable.kennung} kennung={variable.kennung} lesend={lesend}>
{(anfasser) => (
<VariablenKarte
variable={variable}
index={index}
anzahl={variablen.length}
lesend={lesend}
neu={variable.kennung === zuletztAngelegt}
fehler={fehler}
unbenutzt={unbenutzt.has(schluessel)}
anfasser={anfasser}
aendern={(teil) => aendern(variable.kennung, teil)}
loeschen={() => loeschen(variable.kennung)}
umsortieren={umsortieren}
/>
)}
</SortierZeile>
);
})}
</ul>
</SortierBereich>
)}
{lesend ? null : (
<TextKnopf
beschriftung={voll ? `Höchstens ${GRENZEN.variablen} Variablen` : "Variable hinzufügen"}
aus={voll}
aufKlick={hinzufuegen}
/>
)}
</section>
);
}
+507
View File
@@ -0,0 +1,507 @@
"use client";
import { useActionState, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ArrowLeft, Lock, Save, Send, TriangleAlert } from "lucide-react";
import { AbschnittKarte } from "@/components/vorlagen/editor-abschnitt";
import { Kennzeichen, TextKnopf } from "@/components/vorlagen/editor-felder";
import { SortierBereich, SortierZeile } from "@/components/vorlagen/editor-sortieren";
import { VariablenBereich } from "@/components/vorlagen/editor-variablen";
import { PlatzhalterHilfe } from "@/components/vorlagen/platzhalter-hilfe";
import { Vorschau } from "@/components/vorlagen/vorschau";
import {
alsEditorAufbau,
alsStruktur,
neuerAbschnitt,
neuerSchritt,
neueVariable,
umsortieren,
verwendetePlatzhalter,
type EditorAbschnitt,
type EditorAufbau,
type EditorSchritt,
type EditorVariable,
} from "@/lib/editor-aufbau";
import { unbekanntePlatzhalter } from "@/lib/platzhalter";
import { GRENZEN, type Struktur } from "@/lib/vorlagen-format";
import { entwurfSpeichernAktion, veroeffentlichenAktion } from "@/server/actions/vorlagen";
/**
* Der Vorlagen-Editor (#010).
*
* Haelt den ganzen Aufbau im Zustand und schickt ihn beim Speichern als ein
* Stueck JSON an `entwurfSpeichernAktion`. Kein Autosave: eine halb umgebaute
* Vorlage soll nicht nebenbei zur Wahrheit werden, und ein Speichern, das
* niemand ausgeloest hat, ueberschreibt im Zweifel die Arbeit eines anderen.
*
* Eine veroeffentlichte Version wird mit denselben Bauteilen gezeigt, nur mit
* `lesend`. Zwei Codepfade waeren zwei Gelegenheiten, dass die Ansicht etwas
* anderes zeigt als der Editor.
*/
export type EditorVersion = {
readonly id: string;
readonly vorlageId: string;
readonly vorlageName: string;
readonly version: number;
readonly veroeffentlicht: boolean;
readonly struktur: Struktur;
/** Stand beim Laden als ISO-Zeichenkette; geht beim Speichern zurueck. */
readonly stand: string;
readonly aenderungsnotiz: string | null;
};
/**
* Was der Editor ueber das Speichern wissen muss.
*
* Steckt in einem Zustand, weil beides ueber mehrere Versuche hinweg gelten
* muss: der `stand` des letzten erfolgreichen Speicherns und der Aufbau, der
* dabei geschrieben wurde. Nur so kann ein fehlgeschlagener zweiter Versuch
* nicht den Stand des ersten wieder vergessen.
*/
type SpeicherZustand = {
readonly fehler?: string;
readonly erfolg?: string;
readonly stand: string;
readonly gespeichertesJson: string;
};
export function Editor({
version,
lesend,
darfBearbeiten,
beispielDatum,
}: {
version: EditorVersion;
lesend: boolean;
darfBearbeiten: boolean;
/** Heutiges Datum, auf dem Server formatiert — siehe `beispielwerte`. */
beispielDatum: string;
}) {
const router = useRouter();
const [aufbau, setzeAufbau] = useState<EditorAufbau>(() => alsEditorAufbau(version.struktur));
const aktuellesJson = useMemo(() => JSON.stringify(alsStruktur(aufbau)), [aufbau]);
/**
* Der geladene Aufbau, einmal durch den Editor gedreht.
*
* Der Vergleichspunkt fuer „nicht gespeichert“ muss dieselbe Reise gemacht
* haben wie der Zustand: ein Titel, der seine mitgespeicherte Nummer verliert,
* ist keine Aenderung des Benutzers. Die Angleichung wandert mit dem naechsten
* Speichern in die Datenbank.
*/
const anfangsJson = useMemo(
() => JSON.stringify(alsStruktur(alsEditorAufbau(version.struktur))),
[version.struktur],
);
/**
* Das Speichern haengt sich um die Server Action und merkt sich zwei Dinge,
* die sie nicht zurueckgibt: den letzten guten `stand` und den Aufbau, der
* damit geschrieben wurde. Der Aufbau kommt aus dem abgeschickten Formular —
* damit ist es genau der, den der Server gesehen hat, und nicht der, an dem
* jemand waehrend der Anfrage weitergeschrieben hat.
*/
async function speichernMitStand(vorher: SpeicherZustand, formular: FormData): Promise<SpeicherZustand> {
const ergebnis = await entwurfSpeichernAktion({}, formular);
const gesendet = formular.get("struktur");
return {
...ergebnis,
stand: ergebnis.stand ?? vorher.stand,
gespeichertesJson: ergebnis.stand && typeof gesendet === "string" ? gesendet : vorher.gespeichertesJson,
};
}
const [speicherErgebnis, speichern, speichertGerade] = useActionState(speichernMitStand, {
stand: version.stand,
gespeichertesJson: anfangsJson,
});
const [freigabeErgebnis, veroeffentlichen, veroeffentlichtGerade] = useActionState(
veroeffentlichenAktion,
{},
);
const nichtGespeichert = aktuellesJson !== speicherErgebnis.gespeichertesJson;
/**
* Die zuletzt angelegte Zeile wird aufgeklappt gezeigt — alles andere bleibt
* zu. Nur die neueste: aeltere Karten haengen laengst an ihrem eigenen
* Zustand, den sie beim Anlegen bekommen haben, und behalten ihn.
*/
const [zuletztAngelegt, setzeZuletztAngelegt] = useState<string | null>(null);
// Nach dem Veroeffentlichen ist diese Version unveraenderlich. Die Seite muss
// neu geladen werden, damit der Editor in den Lesemodus geht; ein
// revalidatePath auf /vorlagen erreicht diese Adresse nicht.
useEffect(() => {
if (freigabeErgebnis.erfolg) router.refresh();
}, [freigabeErgebnis, router]);
/**
* Der Browser fragt vor dem Verlassen nach, solange etwas nicht gespeichert
* ist. Greift nur bei echtem Verlassen der Seite, nicht bei einem Klick in der
* Navigation — deshalb steht das Kennzeichen zusaetzlich sichtbar oben.
*/
useEffect(() => {
if (lesend || !nichtGespeichert) return undefined;
const warnen = (ereignis: BeforeUnloadEvent) => ereignis.preventDefault();
window.addEventListener("beforeunload", warnen);
return () => window.removeEventListener("beforeunload", warnen);
}, [lesend, nichtGespeichert]);
const verwendet = verwendetePlatzhalter(aufbau);
const unbekannt = unbekanntePlatzhalter(
verwendet,
aufbau.variablen.map((variable) => variable.schluessel.trim()),
);
const konflikt = speicherErgebnis.fehler?.includes("zwischenzeitlich") === true;
// Abschnitte
function abschnitteSetzen(neue: readonly EditorAbschnitt[]) {
setzeAufbau((alt) => ({ ...alt, abschnitte: neue }));
}
function abschnittAendern(kennung: string, teil: Partial<EditorAbschnitt>) {
setzeAufbau((alt) => ({
...alt,
abschnitte: alt.abschnitte.map((abschnitt) =>
abschnitt.kennung === kennung ? { ...abschnitt, ...teil } : abschnitt,
),
}));
}
function schritteSetzen(
abschnittKennung: string,
aendere: (schritte: readonly EditorSchritt[]) => readonly EditorSchritt[],
) {
setzeAufbau((alt) => ({
...alt,
abschnitte: alt.abschnitte.map((abschnitt) =>
abschnitt.kennung === abschnittKennung
? { ...abschnitt, schritte: aendere(abschnitt.schritte) }
: abschnitt,
),
}));
}
// Variablen
function variablenSetzen(neue: readonly EditorVariable[]) {
setzeAufbau((alt) => ({ ...alt, variablen: neue }));
}
const abschnitteVoll = aufbau.abschnitte.length >= GRENZEN.abschnitte;
return (
<div className="flex flex-col gap-6">
<header className="flex flex-col gap-3">
<Link
href={`/vorlagen/${version.vorlageId}`}
className="flex min-h-11 items-center gap-2 self-start text-sm text-nebentext hover:text-text md:min-h-10"
>
<ArrowLeft className="size-[18px]" strokeWidth={1.75} aria-hidden="true" />
Zur Vorlage
</Link>
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-bold">{version.vorlageName}</h1>
<Kennzeichen>Version {version.version}</Kennzeichen>
<Kennzeichen ton={version.veroeffentlicht ? "akzent" : "still"}>
{version.veroeffentlicht ? "veröffentlicht" : "Entwurf"}
</Kennzeichen>
{lesend ? null : nichtGespeichert ? (
<Kennzeichen ton="faellig">Nicht gespeichert</Kennzeichen>
) : (
<Kennzeichen>gespeichert</Kennzeichen>
)}
</div>
</header>
{lesend ? (
<div className="flex items-start gap-3 rounded-karte border border-linie bg-flaeche p-4">
<Lock
className="mt-0.5 size-[18px] shrink-0 text-nebentext"
strokeWidth={1.75}
aria-hidden="true"
/>
<p className="max-w-prose text-sm text-nebentext">
{version.veroeffentlicht ? (
<>
Diese Version ist veröffentlicht und wird nicht mehr verändert — Läufe hängen daran.{" "}
{darfBearbeiten ? (
<>
„Bearbeiten“ auf der{" "}
<Link
href={`/vorlagen/${version.vorlageId}`}
className="text-akzent underline underline-offset-2"
>
Vorlagenseite
</Link>{" "}
erzeugt daraus einen neuen Entwurf; je Vorlage gibt es höchstens einen.
</>
) : (
"Ein neuer Entwurf entsteht über „Bearbeiten“ auf der Vorlagenseite; dafür braucht es die Rolle Redakteur oder Administrator."
)}
</>
) : (
"Dieser Entwurf lässt sich ansehen, aber nicht ändern — dafür braucht es die Rolle Redakteur oder Administrator."
)}
</p>
</div>
) : null}
{unbekannt.length > 0 ? (
<div className="flex items-start gap-3 rounded-karte border border-linie bg-flaeche p-4">
<TriangleAlert
className="mt-0.5 size-[18px] shrink-0 text-faellig"
strokeWidth={1.75}
aria-hidden="true"
/>
<div className="min-w-0">
<p className="text-sm text-faellig">
{unbekannt.length === 1
? "Ein Platzhalter gehört zu keiner Variablen und zu keinem eingebauten Wert:"
: `${unbekannt.length} Platzhalter gehören zu keiner Variablen und zu keinem eingebauten Wert:`}
</p>
<ul className="mt-2 flex flex-wrap gap-2">
{unbekannt.map((schluessel) => (
<li key={schluessel}>
<code className="mono rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-faellig">
{`{{${schluessel}}}`}
</code>
</li>
))}
</ul>
<p className="mt-2 max-w-prose text-sm text-nebentext">
Im Lauf bleibt ein unbekannter Platzhalter als Text stehen. Speichern und Veröffentlichen sind
trotzdem möglich — vielleicht kommt die Variable ja noch.
</p>
</div>
</div>
) : null}
<VariablenBereich
variablen={aufbau.variablen}
verwendet={verwendet}
lesend={lesend}
zuletztAngelegt={zuletztAngelegt}
aendern={(kennung, teil) =>
variablenSetzen(
aufbau.variablen.map((variable) =>
variable.kennung === kennung ? { ...variable, ...teil } : variable,
),
)
}
hinzufuegen={() => {
const variable = neueVariable();
setzeZuletztAngelegt(variable.kennung);
variablenSetzen([...aufbau.variablen, variable]);
}}
loeschen={(kennung) =>
variablenSetzen(aufbau.variablen.filter((variable) => variable.kennung !== kennung))
}
umsortieren={(von, nach) => variablenSetzen(umsortieren(aufbau.variablen, von, nach))}
/>
<section className="flex flex-col gap-3">
<h2 className="text-lg font-semibold">Abschnitte</h2>
{aufbau.abschnitte.length === 0 ? (
<p className="text-sm text-nebentext">
Noch kein Abschnitt. Ein Abschnitt fasst die Schritte zusammen, die zusammengehören.
</p>
) : (
<SortierBereich
kennungen={aufbau.abschnitte.map((abschnitt) => abschnitt.kennung)}
art="Abschnitt"
lesend={lesend}
aufSortiert={(von, nach) => abschnitteSetzen(umsortieren(aufbau.abschnitte, von, nach))}
>
<ul className="flex flex-col gap-4">
{aufbau.abschnitte.map((abschnitt, index) => (
<SortierZeile key={abschnitt.kennung} kennung={abschnitt.kennung} lesend={lesend}>
{(anfasser) => (
<AbschnittKarte
abschnitt={abschnitt}
index={index}
anzahl={aufbau.abschnitte.length}
lesend={lesend}
zuletztAngelegt={zuletztAngelegt}
anfasser={anfasser}
aendern={(teil) => abschnittAendern(abschnitt.kennung, teil)}
loeschen={() =>
abschnitteSetzen(
aufbau.abschnitte.filter((eintrag) => eintrag.kennung !== abschnitt.kennung),
)
}
aufSortiert={(von, nach) => abschnitteSetzen(umsortieren(aufbau.abschnitte, von, nach))}
schrittAendern={(kennung, teil) =>
schritteSetzen(abschnitt.kennung, (schritte) =>
schritte.map((schritt) =>
schritt.kennung === kennung ? { ...schritt, ...teil } : schritt,
),
)
}
schrittHinzufuegen={() => {
const schritt = neuerSchritt();
setzeZuletztAngelegt(schritt.kennung);
schritteSetzen(abschnitt.kennung, (schritte) => [...schritte, schritt]);
}}
schrittLoeschen={(kennung) =>
schritteSetzen(abschnitt.kennung, (schritte) =>
schritte.filter((schritt) => schritt.kennung !== kennung),
)
}
schritteUmsortieren={(von, nach) =>
schritteSetzen(abschnitt.kennung, (schritte) => umsortieren(schritte, von, nach))
}
/>
)}
</SortierZeile>
))}
</ul>
</SortierBereich>
)}
{lesend ? null : (
<TextKnopf
beschriftung={
abschnitteVoll ? `Höchstens ${GRENZEN.abschnitte} Abschnitte` : "Abschnitt hinzufügen"
}
aus={abschnitteVoll}
aufKlick={() => {
const abschnitt = neuerAbschnitt();
setzeZuletztAngelegt(abschnitt.kennung);
abschnitteSetzen([...aufbau.abschnitte, abschnitt]);
}}
/>
)}
</section>
{/* Ohne Set stuende ein doppelt vergebener Schluessel zweimal in der Hilfe. */}
<PlatzhalterHilfe
variablen={[
...new Set(
aufbau.variablen
.map((variable) => variable.schluessel.trim())
.filter((schluessel) => schluessel !== ""),
),
]}
/>
<Vorschau aufbau={aufbau} beispielDatum={beispielDatum} />
{lesend ? null : (
<>
<section className="flex flex-col gap-3 rounded-karte border border-linie bg-flaeche p-4">
<h2 className="text-lg font-semibold">Veröffentlichen</h2>
<p className="max-w-prose text-sm text-nebentext">
Aus dem Entwurf wird Version {version.version}. Sie ist danach unveränderlich, und Läufe können
daran starten. Gespeichert wird veröffentlicht — nicht, was hier gerade auf dem Bildschirm
steht.
</p>
<form action={veroeffentlichen} className="flex flex-col gap-3">
<input type="hidden" name="versionId" value={version.id} />
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Änderungsnotiz (optional)</span>
<textarea
name="aenderungsnotiz"
rows={2}
defaultValue={version.aenderungsnotiz ?? ""}
maxLength={GRENZEN.text}
className="min-h-11 w-full rounded-karte border border-linie bg-grund px-3 py-2 text-sm text-text outline-none focus-visible:border-akzent"
/>
</label>
{nichtGespeichert ? (
<p className="flex items-start gap-2 text-sm text-faellig">
<TriangleAlert className="mt-0.5 size-4 shrink-0" strokeWidth={1.75} aria-hidden="true" />
Es gibt nicht gespeicherte Änderungen. Sie wären in der veröffentlichten Version nicht
enthalten — erst speichern.
</p>
) : null}
{freigabeErgebnis.fehler ? (
<p role="alert" className="text-sm text-fehler">
{freigabeErgebnis.fehler}
</p>
) : null}
{freigabeErgebnis.erfolg ? (
<p role="status" className="text-sm text-erledigt">
{freigabeErgebnis.erfolg}
</p>
) : null}
<button
type="submit"
disabled={veroeffentlichtGerade}
className="flex min-h-11 items-center gap-2 self-start rounded-pille border border-linie px-5 text-sm font-semibold text-akzent hover:bg-akzent-flaeche disabled:pointer-events-none disabled:opacity-40 md:min-h-10"
>
<Send className="size-[18px]" strokeWidth={1.75} aria-hidden="true" />
{veroeffentlichtGerade ? "Wird veröffentlicht …" : "Veröffentlichen"}
</button>
</form>
</section>
<form
action={speichern}
className="sticky bottom-0 z-10 -mx-4 flex flex-col gap-2 border-t border-linie bg-flaeche px-4 py-3 md:-mx-8 md:px-8"
>
<input type="hidden" name="versionId" value={version.id} />
<input type="hidden" name="stand" value={speicherErgebnis.stand} />
<input type="hidden" name="struktur" value={aktuellesJson} />
<div className="flex flex-wrap items-center gap-3">
<button
type="submit"
disabled={speichertGerade}
className="flex min-h-11 items-center gap-2 rounded-pille bg-akzent px-5 text-sm font-semibold text-auf-akzent hover:bg-akzent/80 disabled:pointer-events-none disabled:opacity-40 md:min-h-10"
>
<Save className="size-[18px]" strokeWidth={2} aria-hidden="true" />
{speichertGerade ? "Wird gespeichert …" : "Speichern"}
</button>
<span className={`text-sm ${nichtGespeichert ? "text-faellig" : "text-nebentext"}`}>
{nichtGespeichert ? "Nicht gespeicherte Änderungen" : "Alles gespeichert"}
</span>
</div>
{speicherErgebnis.fehler ? (
<p role="alert" className="text-sm text-fehler">
{speicherErgebnis.fehler}
</p>
) : null}
{speicherErgebnis.erfolg && !nichtGespeichert ? (
<p role="status" className="text-sm text-erledigt">
{speicherErgebnis.erfolg}
</p>
) : null}
{/*
* Der Rettungsanker haengt an **jedem** fehlgeschlagenen Speichern,
* nicht nur am Wettlauf zweier Redakteure. Wer eine Stunde an einer
* Vorlage gesessen hat, soll seinen Aufbau herauskopieren koennen,
* egal woran es lag — an einer abgelaufenen Anmeldung, an einem
* leeren Pflichtfeld oder an der Datenbank.
*/}
{speicherErgebnis.fehler ? (
<div className="flex flex-col gap-2">
{konflikt ? (
<p className="max-w-prose text-sm text-nebentext">
Die Eingaben stehen noch hier und sind nicht verloren — ein Neuladen dieser Seite würde
sie allerdings verwerfen. Am besten den Entwurf in einem zweiten Tab öffnen, die fremde
Fassung ansehen und die eigenen Änderungen dort nachtragen.
</p>
) : (
<p className="max-w-prose text-sm text-nebentext">
Die Eingaben stehen noch hier und sind nicht verloren — ein Neuladen dieser Seite würde
sie allerdings verwerfen.
</p>
)}
<details>
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
Aufbau als JSON herauskopieren
</summary>
<textarea
readOnly
value={aktuellesJson}
rows={6}
className="mono mt-1 w-full rounded-karte border border-linie bg-grund p-3 text-xs text-nebentext"
/>
</details>
</div>
) : null}
</form>
</>
)}
</div>
);
}
@@ -0,0 +1,80 @@
"use client";
import { Kennzeichen } from "@/components/vorlagen/editor-felder";
import { platzhalterNachQuelle } from "@/lib/editor-aufbau";
import { QUELLEN_TEXT, type PlatzhalterQuelle } from "@/lib/platzhalter";
/**
* Welche Platzhalter zur Verfuegung stehen (#010).
*
* Eingeklappt, weil die Liste lang ist und nur gebraucht wird, wenn man einen
* Platzhalter sucht. Gruppiert nach Quelle, damit sichtbar ist, woraus ein Wert
* kommt — und was im Modus Intern leer bleibt.
*/
export function PlatzhalterHilfe({ variablen }: { variablen: readonly string[] }) {
const gruppen = platzhalterNachQuelle();
const quellen = Object.entries(QUELLEN_TEXT) as readonly [PlatzhalterQuelle, string][];
return (
<details className="rounded-karte border border-linie bg-flaeche">
<summary className="flex min-h-11 cursor-pointer list-none items-center px-4 text-sm text-nebentext hover:text-text md:min-h-12">
Welche Platzhalter gibt es?
</summary>
<div className="flex flex-col gap-5 px-4 pt-1 pb-4">
<p className="max-w-prose text-sm text-nebentext">
Ein Platzhalter steht als <code className="mono">{"{{schluessel}}"}</code> im Titel, im Hilfetext
oder im Befehl und wird beim Abarbeiten ersetzt. Leerzeichen in den Klammern sind erlaubt.
</p>
<div>
<h3 className="text-sm font-semibold">Variablen dieser Vorlage</h3>
{variablen.length === 0 ? (
<p className="mt-1 text-sm text-nebentext">Noch keine.</p>
) : (
<ul className="mt-2 flex flex-wrap gap-2">
{variablen.map((schluessel) => (
<li key={schluessel}>
<code className="mono rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-text">
{`{{${schluessel}}}`}
</code>
</li>
))}
</ul>
)}
</div>
{quellen.map(([quelle, text]) => {
const eintraege = gruppen.get(quelle) ?? [];
if (eintraege.length === 0) return null;
return (
<div key={quelle}>
<h3 className="text-sm font-semibold">{text}</h3>
<ul className="mt-2 flex flex-col gap-1.5">
{eintraege.map((platzhalter) => (
<li
key={platzhalter.schluessel}
className="flex flex-col gap-1 sm:flex-row sm:items-baseline sm:gap-3"
>
<code className="mono shrink-0 text-xs text-akzent">
{`{{${platzhalter.schluessel}}}`}
</code>
<span className="text-xs text-nebentext">
{platzhalter.erklaerung}
{platzhalter.nurDienstleistung ? (
<>
{" "}
<Kennzeichen>nur im Modus Dienstleistung</Kennzeichen>
</>
) : null}
</span>
</li>
))}
</ul>
</div>
);
})}
</div>
</details>
);
}
+224
View File
@@ -0,0 +1,224 @@
"use client";
import { useState } from "react";
import { ChevronDown, ExternalLink, Eye } from "lucide-react";
import { Markdown } from "@/components/markdown";
import { Kennzeichen } from "@/components/vorlagen/editor-felder";
import {
BEISPIEL_PASSWORT,
STANDARD_SHELL,
abschnittNummer,
beispielwerte,
mitBeispielwerten,
verwendetePlatzhalter,
type EditorAufbau,
type EditorSchritt,
} from "@/lib/editor-aufbau";
/**
* Die Vorlage so, wie sie im Lauf aussieht (#010).
*
* Reine Darstellung: jedes Feld ist abgeschaltet, nichts wird gespeichert, und
* die Platzhalter sind mit erfundenen Beispielwerten gefuellt. Das steht auch
* oben dran — sonst haelt jemand den Beispielnamen fuer einen echten Datensatz.
*
* Fuer <code>generiertes_passwort</code> entsteht hier **kein** Passwort. Es wird
* je Anzeige neu erzeugt und nirgends gespeichert (#012); eine Vorschau, die
* eines erzeugte, waere der erste Ort, an dem eines an einer unerwarteten Stelle
* steht.
*/
const FELD_AUS = "min-h-11 w-full rounded-karte border border-linie bg-grund px-3 text-sm text-nebentext";
function SchrittFeld({ schritt, werte }: { schritt: EditorSchritt; werte: Record<string, string> }) {
switch (schritt.typ) {
case "HAKEN":
return (
<label className="flex min-h-11 items-center gap-2 text-sm text-nebentext">
<input type="checkbox" disabled className="size-5 accent-akzent" />
erledigt
</label>
);
case "TEXT":
return schritt.konfig.mehrzeilig === true ? (
<textarea disabled rows={3} className={`${FELD_AUS} py-2`} />
) : (
<input disabled className={FELD_AUS} />
);
case "ZAHL":
return (
<div className="flex items-center gap-2">
<input type="number" disabled className={`${FELD_AUS} sm:max-w-40`} />
{schritt.konfig.einheit ? (
<span className="text-sm text-nebentext">{schritt.konfig.einheit}</span>
) : null}
{schritt.konfig.min !== undefined || schritt.konfig.max !== undefined ? (
<span className="text-xs text-nebentext">
{schritt.konfig.min !== undefined ? `ab ${schritt.konfig.min}` : ""}
{schritt.konfig.min !== undefined && schritt.konfig.max !== undefined ? " " : ""}
{schritt.konfig.max !== undefined ? `bis ${schritt.konfig.max}` : ""}
</span>
) : null}
</div>
);
case "AUSWAHL":
return (
<select disabled className={`${FELD_AUS} sm:max-w-72`}>
<option>{schritt.konfig.optionen?.[0] ?? "— keine Option hinterlegt —"}</option>
</select>
);
case "DATUM":
return <input type="date" disabled className={`${FELD_AUS} sm:max-w-48`} />;
case "BEFEHL":
return (
<div className="flex flex-col gap-2">
<Kennzeichen>{schritt.konfig.shell ?? STANDARD_SHELL}</Kennzeichen>
<pre className="mono overflow-x-auto rounded-karte border border-linie bg-grund p-3 text-xs whitespace-pre-wrap">
{mitBeispielwerten(schritt.konfig.befehl ?? "", werte) || "— kein Befehl hinterlegt —"}
</pre>
</div>
);
default:
return null;
}
}
/**
* Die Vorschau haengt an einem Schalter und wird nur dann gebaut, wenn sie
* jemand sehen will.
*
* Zwei Gruende, beide gemessen: sie macht bei 390 px rund 1 500 px Seitenhoehe
* aus, und sie rechnet bei **jedem Tastendruck** die ganze Vorlage samt Markdown
* neu. Bei einer Vorlage mit 150 Schritten waere das bei jedem Buchstaben
* spuerbar. Zugeklappt wird sie nicht nur ausgeblendet, sondern gar nicht erst
* gerendert — sie haelt keinen Zustand, es geht also nichts verloren.
*/
/** Nur `http` und `https` – dieselbe Regel, die `SchrittSchema` beim Speichern anwendet. */
function istWebAdresse(roh: string): boolean {
const wert = roh.trim();
return /^https?:\/\//iu.test(wert);
}
export function Vorschau({ aufbau, beispielDatum }: { aufbau: EditorAufbau; beispielDatum: string }) {
const [offen, setzeOffen] = useState(false);
return (
<section className="flex flex-col gap-4 rounded-karte border border-linie bg-flaeche p-4">
<button
type="button"
onClick={() => setzeOffen((vorher) => !vorher)}
aria-expanded={offen}
className="flex min-h-11 items-center gap-3 text-left md:min-h-10"
>
<ChevronDown
className={`size-4 shrink-0 text-nebentext ${offen ? "" : "-rotate-90"}`}
strokeWidth={2}
aria-hidden="true"
/>
<Eye className="size-[18px] shrink-0 text-akzent" strokeWidth={1.75} aria-hidden="true" />
<h2 className="text-lg font-semibold">Vorschau</h2>
<span className="text-sm text-nebentext">mit Beispielwerten</span>
</button>
{offen ? <VorschauInhalt aufbau={aufbau} beispielDatum={beispielDatum} /> : null}
</section>
);
}
function VorschauInhalt({ aufbau, beispielDatum }: { aufbau: EditorAufbau; beispielDatum: string }) {
const werte = beispielwerte(aufbau.variablen, beispielDatum);
const mitPasswort = verwendetePlatzhalter(aufbau).includes("generiertes_passwort");
return (
<>
<div>
<p className="max-w-prose text-sm text-nebentext">
So sieht die Vorlage im Lauf aus. Alle Platzhalter sind mit{" "}
<span className="text-faellig">erfundenen Beispielwerten</span> gefüllt, die Felder sind
abgeschaltet, und nichts davon wird gespeichert.
</p>
{mitPasswort ? (
<p className="mt-2 max-w-prose text-sm text-nebentext">
<code className="mono">{"{{generiertes_passwort}}"}</code> steht hier als{" "}
<code className="mono">{BEISPIEL_PASSWORT}</code>. Das echte Passwort entsteht erst beim
Abarbeiten, wird bei jeder Anzeige neu erzeugt und nirgends gespeichert.
</p>
) : null}
</div>
{aufbau.variablen.length > 0 ? (
<div className="rounded-karte border border-linie bg-flaeche-hoch p-3">
<h3 className="text-sm font-semibold">Beim Start abgefragt</h3>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
{aufbau.variablen.map((variable) => (
<label key={variable.kennung} className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">
{variable.beschriftung || variable.schluessel || "ohne Beschriftung"}
{variable.pflicht ? <span className="text-fehler"> *</span> : null}
</span>
<input disabled value={werte[variable.schluessel.trim()] ?? ""} className={FELD_AUS} />
</label>
))}
</div>
</div>
) : null}
{aufbau.abschnitte.length === 0 ? (
<p className="text-sm text-nebentext">Noch kein Abschnitt — es gibt nichts abzuarbeiten.</p>
) : (
<ol className="flex flex-col gap-5">
{aufbau.abschnitte.map((abschnitt, index) => (
<li key={abschnitt.kennung}>
<h3 className="flex items-baseline gap-2 text-base font-semibold">
<span className="zahl text-akzent">{abschnittNummer(index)}</span>
<span className="text-nebentext">·</span>
<span>{mitBeispielwerten(abschnitt.titel, werte) || "Ohne Titel"}</span>
</h3>
<ul className="mt-3 flex flex-col gap-4">
{abschnitt.schritte.map((schritt) => (
<li key={schritt.kennung} className="flex flex-col gap-2 border-l border-linie pl-3">
<p className="text-sm">
{mitBeispielwerten(schritt.titel, werte) || "Ohne Titel"}
{schritt.pflicht ? (
<span className="text-fehler" title="Pflichtschritt">
{" *"}
</span>
) : null}
</p>
{schritt.hilfetext.trim() === "" ? null : (
<Markdown text={mitBeispielwerten(schritt.hilfetext, werte)} />
)}
<SchrittFeld schritt={schritt} werte={werte} />
{/*
* Nur http und https werden verlinkt. Im Editor ist der
* Doku-Link noch ungeprueft — das Schema sieht ihn erst
* beim Speichern. Ein `data:`-Verweis in der Vorschau waere
* die einzige Stelle, an der er anklickbar wuerde.
*/}
{!istWebAdresse(schritt.dokuUrl) ? null : (
<a
href={schritt.dokuUrl.trim()}
target="_blank"
rel="noreferrer"
className="flex min-h-11 items-center gap-1.5 text-sm text-akzent underline underline-offset-2 md:min-h-10"
>
<ExternalLink className="size-4" strokeWidth={1.75} aria-hidden="true" />
Anleitung
</a>
)}
</li>
))}
</ul>
</li>
))}
</ol>
)}
</>
);
}
+432
View File
@@ -0,0 +1,432 @@
import { EINGEBAUTE_PLATZHALTER, type EingebauterPlatzhalter } from "@/lib/platzhalter";
import {
SCHRITT_TYPEN,
VARIABLEN_TYPEN,
VariableSchema,
platzhalterDerStruktur,
platzhalterFinden,
type SchrittKonfig,
type Struktur,
} from "@/lib/vorlagen-format";
/**
* Der Aufbau einer Version, wie ihn der Editor im Zustand haelt (#010).
*
* Framework-frei und ohne React, damit sich Umsortieren, Nummerierung, der
* Umgang mit `konfig` beim Typwechsel und die Beispielwerte einzeln pruefen
* lassen — fuer Komponenten gibt es keine DOM-Testumgebung.
*
* Unterschied zur `Struktur` aus `vorlagen-format.ts`: jeder Eintrag traegt eine
* `kennung`. Die Reihenfolge einer Liste ist die fachliche Reihenfolge, aber
* React und `@dnd-kit` brauchen einen Schluessel, der beim Umsortieren am
* Eintrag bleibt und nicht am Index haengt. Die Kennung entsteht im Browser und
* wird nie gespeichert.
*/
export type SchrittTyp = (typeof SCHRITT_TYPEN)[number];
export type VariablenTyp = (typeof VARIABLEN_TYPEN)[number];
export type EditorSchritt = {
readonly kennung: string;
readonly titel: string;
/** Leer statt `undefined`: ein Formularfeld hat immer einen Wert. */
readonly hilfetext: string;
readonly typ: SchrittTyp;
readonly pflicht: boolean;
readonly konfig: SchrittKonfig;
readonly dokuUrl: string;
};
export type EditorAbschnitt = {
readonly kennung: string;
readonly titel: string;
readonly schritte: readonly EditorSchritt[];
};
export type EditorVariable = {
readonly kennung: string;
readonly schluessel: string;
readonly beschriftung: string;
readonly typ: VariablenTyp;
readonly optionen: readonly string[];
readonly pflicht: boolean;
};
export type EditorAufbau = {
readonly variablen: readonly EditorVariable[];
readonly abschnitte: readonly EditorAbschnitt[];
};
let zaehler = 0;
/**
* Eine Kennung, die nur im Browser gilt.
*
* Sie dient als React-`key` und als `id` fuer `@dnd-kit` und wird **nie** in ein
* Attribut gerendert. Deshalb darf sie sich zwischen Vorrendern auf dem Server
* und Hydrieren im Browser unterscheiden — der Zaehler steht im Modul und laeuft
* im Serverprozess ueber alle Anfragen weiter, im Browser faengt er bei null an.
* Gebraucht wird nur Eindeutigkeit innerhalb eines Editors, und die liefert er.
*/
export function neueKennung(praefix: string): string {
zaehler += 1;
return `${praefix}-${zaehler}`;
}
/** Die angezeigte Abschnittsnummer, `01` fuer den ersten. Aus der Position, nie gespeichert. */
export function abschnittNummer(index: number): string {
return String(index + 1).padStart(2, "0");
}
/**
* Eine vorangestellte Nummer aus einem Abschnittstitel entfernen.
*
* Die Nummer wird aus der Position abgeleitet und daneben angezeigt. Traegt ein
* importierter Titel sie zusaetzlich im Text — `01 · Vorbereitung`, wie es die
* Doku bisher vorschlug —, stuende sonst „01 · 01 · Vorbereitung“ da, und beim
* Verschieben wuerde die falsche Nummer mitwandern.
*
* Erkannt wird **nur** der Mittelpunkt als Trenner. Mit `.`, `)` oder `-` waere
* aus „2. Etage prüfen“ stillschweigend „Etage prüfen“ geworden: echter
* Textverlust an einer Stelle, die niemand kontrolliert.
*
* Und auch beim Mittelpunkt muss hinter dem Trenner etwas stehen, das weder
* Ziffer noch Leerraum ist: „24 · 7 Bereitschaft“ ist eine Servicezeit und keine
* Nummerierung. Der Leerraum gehoert in die Bedingung, weil `\s*` sonst
* zurueckspringt und die Vorausschau am Leerzeichen statt an der Ziffer prueft.
* Ohne diese Bedingung waere daraus „7 Bereitschaft“ geworden — derselbe stille
* Verlust, nur seltener.
*/
export function titelOhneNummer(titel: string): string {
return titel.replace(/^\s*\d{1,2}\s*·\s*(?=[^\s\d])/u, "");
}
/**
* Einen Eintrag von einer Position auf eine andere schieben.
*
* Gibt die unveraenderte Liste zurueck, wenn nichts zu tun oder der Index
* ausserhalb ist — so koennen die Knoepfe „nach oben“ und „nach unten“ ohne
* eigene Randpruefung rechnen.
*/
export function umsortieren<T>(liste: readonly T[], von: number, nach: number): readonly T[] {
if (von === nach) return liste;
if (von < 0 || nach < 0 || von >= liste.length || nach >= liste.length) return liste;
const kopie = [...liste];
const [eintrag] = kopie.splice(von, 1);
// Kann nicht eintreten, der Index ist oben geprueft; TypeScript weiss das nicht.
if (eintrag === undefined) return liste;
kopie.splice(nach, 0, eintrag);
return kopie;
}
/**
* Welche `konfig`-Schluessel zu welchem Schritt-Typ gehoeren.
*
* Dieselbe Aufteilung, die `docs/vorlagen-format.md` beschreibt — hier als
* Daten, damit das typabhaengige Formular und die Aufraeumung beim Typwechsel
* aus einer Quelle lesen.
*/
export const KONFIG_SCHLUESSEL: Record<SchrittTyp, readonly string[]> = {
HAKEN: [],
TEXT: ["mehrzeilig"],
ZAHL: ["min", "max", "einheit"],
AUSWAHL: ["optionen"],
DATUM: [],
BEFEHL: ["shell", "befehl"],
};
const ALLE_KONFIG_SCHLUESSEL: ReadonlySet<string> = new Set(Object.values(KONFIG_SCHLUESSEL).flat());
/**
* Die `konfig` beim Wechsel des Schritt-Typs aufraeumen.
*
* Entfernt wird, was einem der bekannten Typen gehoert, aber nicht dem neuen:
* bliebe der `befehl` an einem Schritt haengen, der inzwischen `HAKEN` ist,
* waere er unsichtbarer Ballast — und seine Platzhalter zaehlten weiter zur
* Warnung, ohne dass irgendwo ein Feld zu sehen ist, das man korrigieren
* koennte. Das Format erlaubt fremde Schluessel ausdruecklich (sie sollen einen
* Rundlauf durch eine aeltere Fassung ueberleben), deshalb bleibt alles
* Unbekannte stehen: es gehoert keinem Typ, den diese Fassung kennt, und nichts
* hier wertet es aus.
*/
export function konfigFuerTyp(konfig: SchrittKonfig, typ: SchrittTyp): SchrittKonfig {
const erlaubt = new Set(KONFIG_SCHLUESSEL[typ]);
const gesaeubert: Record<string, unknown> = {};
for (const [schluessel, wert] of Object.entries(konfig)) {
if (erlaubt.has(schluessel) || !ALLE_KONFIG_SCHLUESSEL.has(schluessel)) {
gesaeubert[schluessel] = wert;
}
}
return gesaeubert as SchrittKonfig;
}
/**
* Beschriftungen der Typen — in `src/lib`, weil Editor und Vorschau sie im
* Browser brauchen und nichts aus `src/server` dorthin gehoert.
*/
export const SCHRITT_TYP_TEXT: Record<SchrittTyp, string> = {
HAKEN: "Haken",
TEXT: "Text",
ZAHL: "Zahl",
AUSWAHL: "Auswahl",
DATUM: "Datum",
BEFEHL: "Befehl",
};
export const VARIABLEN_TYP_TEXT: Record<VariablenTyp, string> = {
TEXT: "Text",
ZAHL: "Zahl",
AUSWAHL: "Auswahl",
DATUM: "Datum",
};
/**
* Die Shell, die ein `BEFEHL`-Schritt ohne Angabe bedeutet.
*
* Formular und Vorschau greifen auf dieselbe Annahme zurueck, damit die Vorschau
* nicht etwas anderes zeigt als das Feld darueber. Gespeichert wird sie, sobald
* jemand den Typ auf `BEFEHL` stellt; bei einer importierten Datei ohne `shell`
* bleibt sie eine Anzeige-Annahme.
*/
export const STANDARD_SHELL = "powershell";
/** Ein leerer Schritt, wie ihn „Schritt hinzufügen“ anlegt. */
export function neuerSchritt(): EditorSchritt {
return {
kennung: neueKennung("schritt"),
titel: "",
hilfetext: "",
typ: "HAKEN",
pflicht: false,
konfig: {},
dokuUrl: "",
};
}
export function neuerAbschnitt(): EditorAbschnitt {
return { kennung: neueKennung("abschnitt"), titel: "", schritte: [] };
}
export function neueVariable(): EditorVariable {
return {
kennung: neueKennung("variable"),
schluessel: "",
beschriftung: "",
typ: "TEXT",
optionen: [],
pflicht: false,
};
}
/** Die geladene Struktur in den Editor-Zustand uebernehmen. */
export function alsEditorAufbau(struktur: Struktur): EditorAufbau {
return {
variablen: struktur.variablen.map((variable) => ({
kennung: neueKennung("variable"),
schluessel: variable.schluessel,
beschriftung: variable.beschriftung,
typ: variable.typ,
optionen: [...variable.optionen],
pflicht: variable.pflicht,
})),
abschnitte: struktur.abschnitte.map((abschnitt) => ({
kennung: neueKennung("abschnitt"),
// Die Nummer gehoert zur Position, nicht in den Titel — siehe titelOhneNummer.
titel: titelOhneNummer(abschnitt.titel),
schritte: abschnitt.schritte.map((schritt) => ({
kennung: neueKennung("schritt"),
titel: schritt.titel,
hilfetext: schritt.hilfetext ?? "",
typ: schritt.typ,
pflicht: schritt.pflicht,
konfig: schritt.konfig,
dokuUrl: schritt.dokuUrl ?? "",
})),
})),
};
}
/**
* Der Zustand zurueck in die Form, die gespeichert und exportiert wird.
*
* Kennungen fallen weg, leere Textfelder werden zu `undefined` und damit im
* JSON weggelassen — ein leerer Doku-Link ist keine Adresse, und das Schema
* wuerde ihn zu Recht beanstanden.
*/
export function alsStruktur(aufbau: EditorAufbau): Struktur {
return {
variablen: aufbau.variablen.map((variable) => ({
schluessel: variable.schluessel.trim(),
beschriftung: variable.beschriftung,
typ: variable.typ,
// Optionen gehoeren nur zu AUSWAHL; sonst waeren sie beim Typwechsel
// derselbe unsichtbare Ballast wie eine nicht aufgeraeumte konfig.
optionen: variable.typ === "AUSWAHL" ? [...variable.optionen] : [],
pflicht: variable.pflicht,
})),
abschnitte: aufbau.abschnitte.map((abschnitt) => ({
titel: abschnitt.titel,
schritte: abschnitt.schritte.map((schritt) => ({
titel: schritt.titel,
hilfetext: schritt.hilfetext.trim() === "" ? undefined : schritt.hilfetext,
typ: schritt.typ,
pflicht: schritt.pflicht,
konfig: schritt.konfig,
dokuUrl: schritt.dokuUrl.trim() === "" ? undefined : schritt.dokuUrl.trim(),
})),
})),
};
}
/** Alle Platzhalter, die im aktuellen Zustand vorkommen. */
export function verwendetePlatzhalter(aufbau: EditorAufbau): readonly string[] {
return platzhalterDerStruktur(alsStruktur(aufbau));
}
/**
* Ist der Variablenschluessel gueltig — und wenn nicht, warum?
*
* Fragt das Schema selbst, statt sein Muster abzuschreiben: sonst gaebe es zwei
* Regeln, und die im Editor waere die, die niemand nachzieht.
*/
export function schluesselFehler(schluessel: string): string | undefined {
const geprueft = VariableSchema.shape.schluessel.safeParse(schluessel);
if (geprueft.success) return undefined;
return geprueft.error.issues[0]?.message ?? "Der Schlüssel ist ungültig.";
}
/** Die Regel als Satz, fuer den Hinweis unter dem Feld. Kommt aus demselben Schema. */
export const SCHLUESSEL_REGEL: string = schluesselFehler("") ?? "";
/**
* Schluessel, die mehr als einmal vorkommen.
*
* Der Dienst lehnt das beim Speichern ab. Besser, es faellt schon beim Tippen
* auf — mit der doppelten Zeile im Blick.
*/
export function doppelteSchluessel(variablen: readonly EditorVariable[]): readonly string[] {
const gesehen = new Set<string>();
const doppelt = new Set<string>();
for (const variable of variablen) {
const schluessel = variable.schluessel.trim();
if (schluessel === "") continue;
if (gesehen.has(schluessel)) doppelt.add(schluessel);
gesehen.add(schluessel);
}
return [...doppelt];
}
/**
* Beispieltext fuer `{{generiertes_passwort}}`.
*
* Fest und erkennbar unecht. Ein echtes Passwort darf hier nicht entstehen: es
* wird je Anzeige neu erzeugt und nirgends gespeichert (#012), und eine
* Vorschau, die eines zeigt, faengt an, sich auf etwas zu verlassen, das es
* noch nicht gibt.
*/
export const BEISPIEL_PASSWORT = "BEISPIEL-PASSWORT-NICHT-ECHT";
/**
* Beispielwerte der eingebauten Platzhalter.
*
* Erkennbar erfunden, aber in der richtigen Form: ein `dn` sieht aus wie ein
* `dn`, damit in der Vorschau eines PowerShell-Befehls zu sehen ist, was der
* Techniker spaeter wirklich vor sich hat. `datum` fehlt hier bewusst und kommt
* von aussen — siehe `beispielwerte`.
*/
const BEISPIELWERTE: Record<string, string> = {
hostname: "NB-MUSTER-014",
seriennummer: "SN-2026-000123",
inventarnummer: "INV-004711",
ticket: "T-12345",
techniker: "Tina Technik",
kunde: "Muster GmbH",
kundenkuerzel: "MUS",
mitarbeiter: "Anna Beispiel",
benutzer: "Anna Beispiel",
vorname: "Anna",
nachname: "Beispiel",
upn: "anna.beispiel@muster.example",
sam: "a.beispiel",
mail: "anna.beispiel@muster.example",
abteilung: "Vertrieb",
titel: "Sachbearbeiterin",
vorgesetzter: "Bernd Beispiel",
vorgesetzter_sam: "b.beispiel",
dn: "CN=Anna Beispiel,OU=Vertrieb,DC=muster,DC=example",
ou: "OU=Vertrieb,DC=muster,DC=example",
gruppen: "G-Vertrieb, G-Drucker-EG",
generiertes_passwort: BEISPIEL_PASSWORT,
};
function beispielFuerVariable(variable: EditorVariable, beispielDatum: string): string {
switch (variable.typ) {
case "AUSWAHL":
return variable.optionen[0] ?? "Beispielauswahl";
case "ZAHL":
return "42";
case "DATUM":
return beispielDatum;
default:
return `Beispiel-${variable.schluessel.trim() || "wert"}`;
}
}
/**
* Die Beispielwerte fuer die Vorschau.
*
* `beispielDatum` kommt von der Serverseite und nicht aus `new Date()`: der
* Editor wird serverseitig vorgerendert, und zwei verschiedene Uhrzeiten in
* denselben Text ergaeben einen Hydrierungsfehler.
*
* Gibt es einen Schluessel als Variable **und** als eingebauten Wert — etwa
* `abteilung` —, zeigt die Vorschau den Beispielwert der Variablen: sie wird im
* Startformular des Laufs abgefragt und ist damit die konkretere Angabe. Fuer
* Beispieltexte ist der Unterschied ohnehin nur ein anderer erfundener Name.
*/
export function beispielwerte(
variablen: readonly EditorVariable[],
beispielDatum: string,
): Record<string, string> {
const werte: Record<string, string> = { ...BEISPIELWERTE, datum: beispielDatum };
for (const variable of variablen) {
const schluessel = variable.schluessel.trim();
if (schluessel !== "") werte[schluessel] = beispielFuerVariable(variable, beispielDatum);
}
return werte;
}
/**
* Platzhalter im Text durch Beispielwerte ersetzen.
*
* Ein unbekannter Platzhalter bleibt als `{{schluessel}}` stehen — genau so wie
* im Lauf, und die Warnung im Editor sagt dazu, was fehlt. Die Namen kommen aus
* `platzhalterFinden` und bestehen damit nur aus Kleinbuchstaben, Ziffern und
* Unterstrich; im **Suchausdruck** ist daran nichts zu entschaerfen.
*
* Im **Ersatztext** sehr wohl: `String.replace` deutet dort `$&`, `` $` ``, `$'`
* und `$1` als Rueckverweise. Ein Auswahlwert „Kosten $& Nutzen“ haette den
* gefundenen Platzhalter wieder eingesetzt. Eine Ersatzfunktion liefert den Wert
* unveraendert — wichtig spaetestens fuer #012, wo derselbe Weg einen
* PowerShell-Befehl zusammensetzt.
*/
export function mitBeispielwerten(text: string, werte: Record<string, string>): string {
let ergebnis = text;
for (const name of platzhalterFinden(text)) {
const wert = werte[name];
if (wert === undefined) continue;
ergebnis = ergebnis.replace(new RegExp(`\\{\\{\\s*${name}\\s*\\}\\}`, "g"), () => wert);
}
return ergebnis;
}
/** Die eingebauten Platzhalter nach Quelle gruppiert — fuer die Hilfe im Editor. */
export function platzhalterNachQuelle(): ReadonlyMap<string, readonly EingebauterPlatzhalter[]> {
const gruppen = new Map<string, EingebauterPlatzhalter[]>();
for (const platzhalter of EINGEBAUTE_PLATZHALTER) {
const vorhanden = gruppen.get(platzhalter.quelle);
if (vorhanden) vorhanden.push(platzhalter);
else gruppen.set(platzhalter.quelle, [platzhalter]);
}
return gruppen;
}
+1 -1
View File
@@ -25,7 +25,7 @@ export type NavEintrag = {
export const NAVIGATION: readonly NavEintrag[] = [
{ pfad: "/", beschriftung: "Übersicht", symbol: "LayoutDashboard", verfuegbar: true },
{ pfad: "/laeufe", beschriftung: "Läufe", symbol: "ListChecks", verfuegbar: false, ausIssue: 13 },
{ pfad: "/vorlagen", beschriftung: "Vorlagen", symbol: "FileText", verfuegbar: false, ausIssue: 10 },
{ pfad: "/vorlagen", beschriftung: "Vorlagen", symbol: "FileText", verfuegbar: true },
{
pfad: "/mitarbeiter",
beschriftung: "Mitarbeiter & Geräte",
+115
View File
@@ -0,0 +1,115 @@
/**
* Die eingebauten Platzhalter (#010, aufgeloest in #012).
*
* Ein Platzhalter `{{schluessel}}` in Titel, Hilfetext oder Befehl wird beim
* Abarbeiten eines Laufs ersetzt. Woraus, steht hier: entweder aus einer
* Variablen der Vorlage oder aus einer der eingebauten Quellen. Ein Schluessel,
* der zu keiner von beiden passt, bleibt im Lauf als `{{schluessel}}` stehen —
* sichtbar, aber nutzlos. Deshalb warnt der Editor davor.
*
* Die Liste ist bewusst Daten und kein Code: der Editor zeigt sie als Hilfe an,
* die Warnung vergleicht dagegen, und #012 baut die Aufloesung darauf auf. Eine
* zweite Aufzaehlung an anderer Stelle wuerde unweigerlich auseinanderlaufen.
*/
export type PlatzhalterQuelle = "GERAET_LAUF" | "MITARBEITER" | "ABTEILUNG" | "BESONDERS";
export type EingebauterPlatzhalter = {
readonly schluessel: string;
readonly quelle: PlatzhalterQuelle;
readonly erklaerung: string;
/** Nur im Modus DIENSTLEISTUNG gefuellt; im Modus INTERN bleibt der Wert leer. */
readonly nurDienstleistung?: true;
};
export const EINGEBAUTE_PLATZHALTER: readonly EingebauterPlatzhalter[] = [
// Aus Geraet und Lauf
{ schluessel: "hostname", quelle: "GERAET_LAUF", erklaerung: "Hostname des Geräts im Lauf" },
{ schluessel: "seriennummer", quelle: "GERAET_LAUF", erklaerung: "Seriennummer des Geräts" },
{ schluessel: "inventarnummer", quelle: "GERAET_LAUF", erklaerung: "Inventarnummer des Geräts" },
{ schluessel: "ticket", quelle: "GERAET_LAUF", erklaerung: "Ticketnummer am Lauf" },
{ schluessel: "datum", quelle: "GERAET_LAUF", erklaerung: "heutiges Datum" },
{ schluessel: "techniker", quelle: "GERAET_LAUF", erklaerung: "Name des Bearbeiters" },
{
schluessel: "kunde",
quelle: "GERAET_LAUF",
erklaerung: "Name des Kunden",
nurDienstleistung: true,
},
{
schluessel: "kundenkuerzel",
quelle: "GERAET_LAUF",
erklaerung: "Kürzel des Kunden",
nurDienstleistung: true,
},
// Aus dem Mitarbeiter
{ schluessel: "mitarbeiter", quelle: "MITARBEITER", erklaerung: "vollständiger Name" },
{ schluessel: "benutzer", quelle: "MITARBEITER", erklaerung: "gleichbedeutend mit mitarbeiter" },
{ schluessel: "vorname", quelle: "MITARBEITER", erklaerung: "Vorname" },
{ schluessel: "nachname", quelle: "MITARBEITER", erklaerung: "Nachname" },
{ schluessel: "upn", quelle: "MITARBEITER", erklaerung: "User Principal Name" },
{ schluessel: "sam", quelle: "MITARBEITER", erklaerung: "sAMAccountName, höchstens 20 Zeichen" },
{ schluessel: "mail", quelle: "MITARBEITER", erklaerung: "Mail-Adresse" },
{ schluessel: "abteilung", quelle: "MITARBEITER", erklaerung: "Name der Abteilung" },
{ schluessel: "titel", quelle: "MITARBEITER", erklaerung: "Funktion oder Titel" },
{ schluessel: "vorgesetzter", quelle: "MITARBEITER", erklaerung: "Name des Vorgesetzten" },
{ schluessel: "vorgesetzter_sam", quelle: "MITARBEITER", erklaerung: "sAMAccountName des Vorgesetzten" },
{ schluessel: "dn", quelle: "MITARBEITER", erklaerung: "Distinguished Name im Verzeichnis" },
// Aus der Abteilung
{
schluessel: "ou",
quelle: "ABTEILUNG",
erklaerung: "Ziel-Container: Eltern-Container des dn, sonst die Ziel-OU der Abteilung",
},
{ schluessel: "gruppen", quelle: "ABTEILUNG", erklaerung: "Standardgruppen, kommagetrennt" },
// Besonders
{
schluessel: "generiertes_passwort",
quelle: "BESONDERS",
erklaerung:
"20 Zeichen, bei jeder Anzeige neu erzeugt und nirgends gespeichert — auch nicht im Protokoll",
},
];
export const QUELLEN_TEXT: Record<PlatzhalterQuelle, string> = {
GERAET_LAUF: "Gerät und Lauf",
MITARBEITER: "Mitarbeiter",
ABTEILUNG: "Abteilung",
BESONDERS: "Besonders",
};
const EINGEBAUTE_SCHLUESSEL: ReadonlySet<string> = new Set(
EINGEBAUTE_PLATZHALTER.map((platzhalter) => platzhalter.schluessel),
);
export function istEingebaut(schluessel: string): boolean {
return EINGEBAUTE_SCHLUESSEL.has(schluessel);
}
/**
* Die Platzhalter, die weder eingebaut noch als Variable definiert sind.
*
* Das ist eine **Warnung**, keine Ablehnung: ein Tippfehler faellt so im Editor
* auf, statt erst dem Techniker im Lauf. Speichern und Veroeffentlichen bleiben
* erlaubt — es kann gute Gruende geben, einen Platzhalter stehen zu lassen, etwa
* wenn die Variable noch kommt.
*/
export function unbekanntePlatzhalter(
verwendete: readonly string[],
variablenSchluessel: readonly string[],
): readonly string[] {
const bekannt = new Set(variablenSchluessel);
return verwendete.filter((schluessel) => !bekannt.has(schluessel) && !istEingebaut(schluessel));
}
/** Variablen, die definiert sind, aber nirgends verwendet werden. */
export function unbenutzteVariablen(
verwendete: readonly string[],
variablenSchluessel: readonly string[],
): readonly string[] {
const benutzt = new Set(verwendete);
return variablenSchluessel.filter((schluessel) => !benutzt.has(schluessel));
}
+25
View File
@@ -104,6 +104,9 @@ export const AbschnittSchema = z.object({
.default([]),
});
/** Ein Abschnitt so, wie ihn ein Editor oder eine Datei liefert – vor dem Pruefen. */
export type AbschnittEingabe = z.input<typeof AbschnittSchema>;
export const VariableSchema = z.object({
/**
* Der Schluessel steht als `{{schluessel}}` in Titeln, Hilfetexten und
@@ -176,6 +179,28 @@ export type Austausch = z.infer<typeof AustauschSchema>;
* soll, wenn ein Platzhalter zu keiner Variablen und zu keinem eingebauten Wert
* gehoert. Das Ersetzen selbst kommt in #012.
*/
/**
* Aus einem Zod-Pfad eine Stelle machen, die man auf dem Bildschirm wiederfindet.
*
* `struktur.abschnitte.2.schritte.0.titel` ist fuer den Aufbau richtig und fuer
* den Redakteur nutzlos — er muesste von null an zaehlen und wuesste immer noch
* nicht, ob „2“ den dritten Abschnitt meint. Daraus wird „Abschnitt 03,
* Schritt 1: “. Der Rest der Meldung kommt unveraendert aus dem Schema.
*/
export function stelleBenennen(pfad: readonly PropertyKey[]): string {
const teile: string[] = [];
for (let i = 0; i < pfad.length; i += 1) {
const name = String(pfad[i]);
const index = Number(pfad[i + 1]);
if (!Number.isInteger(index)) continue;
if (name === "abschnitte") teile.push(`Abschnitt ${String(index + 1).padStart(2, "0")}`);
else if (name === "schritte") teile.push(`Schritt ${index + 1}`);
else if (name === "variablen") teile.push(`Variable ${index + 1}`);
else if (name === "optionen") teile.push(`Option ${index + 1}`);
}
return teile.length > 0 ? `${teile.join(", ")}: ` : "";
}
export function platzhalterFinden(text: string): readonly string[] {
const gefunden = new Set<string>();
for (const treffer of text.matchAll(/\{\{\s*([a-z][a-z0-9_]*)\s*\}\}/g)) {
+47 -17
View File
@@ -33,6 +33,40 @@ export function istOeffentlich(pfad: string): boolean {
return OEFFENTLICH.some((eintrag) => pfad === eintrag || pfad.startsWith(`${eintrag}/`));
}
/**
* Die Content-Security-Policy als reine Funktion — damit sie sich testen laesst.
*
* Im Entwicklungsmodus braucht es zwei Lockerungen, und nur dort:
*
* - **`unsafe-eval`**: React und Turbopack werten im Entwicklungsmodus Code zur
* Laufzeit aus (Fast Refresh, Aufrufketten ueber Umgebungsgrenzen hinweg).
* Ohne diese Erlaubnis **hydriert die Seite gar nicht** — sie sieht richtig
* aus, aber kein Knopf tut etwas. Das ist der heimtueckische Teil: ein
* Screenshot sieht aus wie immer, und erst ein Klick faellt auf.
* - **`ws:` und `wss:`**: Fast Refresh haelt eine WebSocket-Verbindung.
*
* Im gebauten Stand braucht React beides nicht. Die ausgelieferte Anwendung
* bekommt deshalb weiterhin die strenge Fassung — ein Test haelt das fest,
* damit die Lockerung nicht eines Tages mitgeliefert wird.
*/
export function sicherheitsrichtlinie(nonce: string, entwicklung: boolean): string {
return [
`default-src 'self'`,
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${entwicklung ? " 'unsafe-eval'" : ""}`,
// Tailwind erzeugt Stilangaben zur Laufzeit, deshalb unsafe-inline fuer Stile.
`style-src 'self' 'unsafe-inline'`,
`img-src 'self' data: blob:`,
`font-src 'self'`,
// Die Kamera des Scanners liefert Bilddaten als blob:.
`media-src 'self' blob:`,
`connect-src 'self'${entwicklung ? " ws: wss:" : ""}`,
`frame-ancestors 'none'`,
`base-uri 'self'`,
`form-action 'self'`,
`object-src 'none'`,
].join("; ");
}
export function proxy(anfrage: NextRequest): NextResponse {
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
const pfad = anfrage.nextUrl.pathname;
@@ -44,21 +78,7 @@ export function proxy(anfrage: NextRequest): NextResponse {
return NextResponse.redirect(anmelden);
}
const richtlinie = [
`default-src 'self'`,
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
// Tailwind erzeugt Stilangaben zur Laufzeit, deshalb unsafe-inline fuer Stile.
`style-src 'self' 'unsafe-inline'`,
`img-src 'self' data: blob:`,
`font-src 'self'`,
// Die Kamera des Scanners liefert Bilddaten als blob:.
`media-src 'self' blob:`,
`connect-src 'self'`,
`frame-ancestors 'none'`,
`base-uri 'self'`,
`form-action 'self'`,
`object-src 'none'`,
].join("; ");
const richtlinie = sicherheitsrichtlinie(nonce, process.env.NODE_ENV !== "production");
const kopfzeilen = new Headers(anfrage.headers);
kopfzeilen.set("x-nonce", nonce);
@@ -74,6 +94,16 @@ export function proxy(anfrage: NextRequest): NextResponse {
}
export const config = {
// Statische Dateien und Bilder brauchen weder CSP-Nonce noch Tor.
matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|webp|woff2?)$).*)"],
/*
* Alles unter `_next` bleibt aussen vor, nicht nur `static` und `image`.
*
* Die CSP gehoert auf das Dokument, nicht auf jeden Programmteil, und das Tor
* schuetzt Seiten, keine Bausteine. Vorher lief der Proxy auch auf
* `/_next/hmr` — der Verbindungsaufbau von Fast Refresh ist ein
* WebSocket-Upgrade, und `NextResponse.next()` mit geaenderten Kopfzeilen
* macht daraus eine gewoehnliche Antwort. Die Verbindung scheiterte, und im
* Entwicklungsmodus hydrierte die Seite nicht mehr: sie sah richtig aus, aber
* kein Knopf tat etwas.
*/
matcher: ["/((?!_next/|favicon.ico|.*\\.(?:svg|png|webp|woff2?)$).*)"],
};
+49 -7
View File
@@ -2,9 +2,9 @@
import { revalidatePath } from "next/cache";
import { z } from "@/lib/validierung";
import { StrukturSchema } from "@/lib/vorlagen-format";
import { StrukturSchema, stelleBenennen } from "@/lib/vorlagen-format";
import { kundeZugriffPruefen, kundenEbeneSichtbar } from "@/server/mandant";
import { rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import {
VorlagenFehler,
entwurfHolenOderErzeugen,
@@ -36,10 +36,40 @@ function alsErgebnis(fehler: unknown, neutral: string): AktionsErgebnis {
if (fehler instanceof VorlagenFehler || fehler instanceof AustauschFehler) {
return { fehler: fehler.message };
}
if (fehler instanceof RechteFehler) {
return { fehler: RECHTE_MELDUNG };
}
console.error(`${neutral}:`, fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : fehler);
return { fehler: neutral };
}
const RECHTE_MELDUNG =
"Deine Anmeldung gilt nicht mehr. Melde dich in einem zweiten Tab neu an und klicke dann hier " +
"noch einmal — was du eingegeben hast, steht weiterhin auf dieser Seite.";
/**
* Die Rechtepruefung so, dass ein abgelaufenes Cookie kein Datenverlust ist.
*
* `rechtErforderlich` wirft. Wird der Wurf nicht gefangen, endet er in Nexts
* Fehlergrenze — die Seite wird ausgehaengt, und im Editor ist damit alles weg,
* was noch nicht gespeichert war. Nach zwoelf Stunden laeuft die Sitzung von
* selbst ab; das trifft genau die Person, die lange an einer Vorlage sitzt.
*
* Deshalb: fangen, und dem Benutzer sagen, was zu tun ist. Der Editor behaelt
* seinen Zustand, weil die Aktion ganz normal ein Ergebnis zurueckgibt.
*/
async function angemeldetOderMeldung(): Promise<
{ readonly benutzerId: string } | { readonly fehler: string }
> {
try {
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
return { benutzerId: benutzer.id };
} catch (fehler) {
if (fehler instanceof RechteFehler) return { fehler: RECHTE_MELDUNG };
throw fehler;
}
}
const AnlegenSchema = z.object({
name: z.string().trim().min(1, "Bitte einen Namen angeben."),
beschreibung: z.string().trim().optional(),
@@ -127,7 +157,8 @@ export async function entwurfSpeichernAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis & { readonly stand?: string }> {
await rechtErforderlich("vorlagenBearbeiten");
const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang;
const roh = formular.get("struktur");
let geparst: unknown;
@@ -145,7 +176,7 @@ export async function entwurfSpeichernAktion(
if (!eingabe.success) {
const erster = eingabe.error.issues[0];
return {
fehler: erster ? `${erster.path.join(".")}: ${erster.message}` : "Der Aufbau ist unvollständig.",
fehler: erster ? `${stelleBenennen(erster.path)}${erster.message}` : "Der Aufbau ist unvollständig.",
};
}
@@ -197,6 +228,16 @@ export async function vorlageBearbeitenAktion(
await kundeZugriffPruefen(kundeId, benutzer);
}
/*
* Ein fehlendes Feld heisst „nicht angefasst“, ein leeres heisst „fuer alle“.
*
* Im Modus Intern zeigt das Formular gar keine Kundenwahl. Ohne diese
* Unterscheidung haette jedes Speichern der Kopfdaten die Kundenzuordnung
* stillschweigend geloescht — und nach einem Wechsel zurueck in den Modus
* Dienstleistung waere die Vorlage ploetzlich fuer alle sichtbar gewesen.
*/
const kundeAngegeben = formular.has("kundeId");
try {
await vorlageBearbeiten(
eingabe.data.vorlageId,
@@ -208,7 +249,7 @@ export async function vorlageBearbeitenAktion(
.split(",")
.map((wort) => wort.trim())
.filter(Boolean),
kundeId,
...(kundeAngegeben ? { kundeId } : {}),
},
benutzer.id,
);
@@ -223,7 +264,8 @@ export async function veroeffentlichenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang;
const eingabe = z
.object({ versionId: z.string().min(1), aenderungsnotiz: z.string().trim().optional() })
.safeParse({
@@ -236,7 +278,7 @@ export async function veroeffentlichenAktion(
const version = await versionVeroeffentlichen(
eingabe.data.versionId,
eingabe.data.aenderungsnotiz ?? null,
benutzer.id,
zugang.benutzerId,
);
revalidatePath("/vorlagen");
return { erfolg: `Version ${version.version} ist veröffentlicht.` };
+82 -35
View File
@@ -1,3 +1,4 @@
import type { Prisma } from "@/generated/prisma/client";
import { VersionStatus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { platzhalterDerStruktur, type Struktur } from "@/lib/vorlagen-format";
@@ -19,6 +20,10 @@ export type VorlagenZeile = {
readonly schlagworte: readonly string[];
readonly archiviert: boolean;
readonly kundeId: string | null;
/** Name des Kunden, wenn die Vorlage kundenspezifisch ist – fuer die Anzeige. */
readonly kundeName: string | null;
/** Kennung des vorhandenen Entwurfs, damit die Liste direkt dorthin verlinkt. */
readonly entwurfVersionId: string | null;
/** Nummer der zuletzt veroeffentlichten Version, falls es eine gibt. */
readonly veroeffentlichteVersion: number | null;
readonly veroeffentlichtAm: Date | null;
@@ -51,6 +56,55 @@ async function kundenBedingung(benutzer: HandelnderBenutzer) {
return { OR: [{ kundeId: null }, { kundeId: { in: [...sichtbar] } }] };
}
/**
* Auswahl und Abbildung stehen getrennt, damit Liste und Einzelabruf garantiert
* dieselben Felder liefern – zwei Modelle fuer dieselbe Zeile laufen mit der
* Zeit auseinander.
*/
const ZEILEN_AUSWAHL = {
id: true,
name: true,
beschreibung: true,
kategorie: true,
schlagworte: true,
archiviert: true,
kundeId: true,
kunde: { select: { name: true } },
versionen: {
select: {
id: true,
version: true,
status: true,
veroeffentlichtAm: true,
_count: { select: { laeufe: true } },
},
},
} satisfies Prisma.VorlageSelect;
type VorlagenRohzeile = Prisma.VorlageGetPayload<{ select: typeof ZEILEN_AUSWAHL }>;
function zeileBauen(zeile: VorlagenRohzeile): VorlagenZeile {
const neueste = zeile.versionen
.filter((version) => version.status === VersionStatus.VEROEFFENTLICHT)
.sort((a, b) => b.version - a.version)[0];
const entwurf = zeile.versionen.find((version) => version.status === VersionStatus.ENTWURF);
return {
id: zeile.id,
name: zeile.name,
beschreibung: zeile.beschreibung,
kategorie: zeile.kategorie,
schlagworte: zeile.schlagworte,
archiviert: zeile.archiviert,
kundeId: zeile.kundeId,
kundeName: zeile.kunde?.name ?? null,
entwurfVersionId: entwurf?.id ?? null,
veroeffentlichteVersion: neueste?.version ?? null,
veroeffentlichtAm: neueste?.veroeffentlichtAm ?? null,
hatEntwurf: entwurf !== undefined,
anzahlLaeufe: zeile.versionen.reduce((summe, version) => summe + version._count.laeufe, 0),
};
}
export async function vorlagenListe(
benutzer: HandelnderBenutzer,
filter: VorlagenFilter = {},
@@ -75,44 +129,29 @@ export async function vorlagenListe(
const zeilen = await prisma.vorlage.findMany({
where: { AND: bedingungen },
orderBy: [{ archiviert: "asc" }, { name: "asc" }],
select: {
id: true,
name: true,
beschreibung: true,
kategorie: true,
schlagworte: true,
archiviert: true,
kundeId: true,
versionen: {
select: {
version: true,
status: true,
veroeffentlichtAm: true,
_count: { select: { laeufe: true } },
},
},
},
select: ZEILEN_AUSWAHL,
});
return zeilen.map(zeileBauen);
}
return zeilen.map((zeile) => {
const veroeffentlichte = zeile.versionen
.filter((v) => v.status === VersionStatus.VEROEFFENTLICHT)
.sort((a, b) => b.version - a.version);
const neueste = veroeffentlichte[0];
return {
id: zeile.id,
name: zeile.name,
beschreibung: zeile.beschreibung,
kategorie: zeile.kategorie,
schlagworte: zeile.schlagworte,
archiviert: zeile.archiviert,
kundeId: zeile.kundeId,
veroeffentlichteVersion: neueste?.version ?? null,
veroeffentlichtAm: neueste?.veroeffentlichtAm ?? null,
hatEntwurf: zeile.versionen.some((v) => v.status === VersionStatus.ENTWURF),
anzahlLaeufe: zeile.versionen.reduce((summe, v) => summe + v._count.laeufe, 0),
};
/**
* Eine einzelne Vorlage mit ihren Kopfdaten.
*
* Bewusst dasselbe Lesemodell wie die Liste: die Vorlagenseite zeigt dieselben
* Angaben, und zwei Modelle fuer dieselbe Zeile laufen mit der Zeit auseinander.
* `undefined` heisst „gibt es nicht oder darfst du nicht sehen“ — die
* Unterscheidung waere schon die Auskunft, die wir nicht geben wollen.
*/
export async function vorlageHolen(
vorlageId: string,
benutzer: HandelnderBenutzer,
): Promise<VorlagenZeile | undefined> {
const mandant = await kundenBedingung(benutzer);
const zeile = await prisma.vorlage.findFirst({
where: { id: vorlageId, ...(mandant ? mandant : {}) },
select: ZEILEN_AUSWAHL,
});
return zeile ? zeileBauen(zeile) : undefined;
}
/** Die Kategorien und Schlagworte, die tatsaechlich vorkommen – fuer die Filter. */
@@ -188,6 +227,12 @@ export type VersionMitAufbau = {
readonly vorlageName: string;
readonly version: number;
readonly status: VersionStatus;
/**
* Stand beim Laden. Der Editor schickt ihn beim Speichern zurueck; weicht er
* ab, hat jemand anderes zwischenzeitlich gespeichert (#009).
*/
readonly stand: Date;
readonly aenderungsnotiz: string | null;
readonly struktur: Struktur;
/** Platzhalter, die in Titeln, Hilfetexten und Befehlen vorkommen. */
readonly platzhalter: readonly string[];
@@ -238,6 +283,8 @@ export async function versionMitAufbau(
vorlageName: version.vorlage.name,
version: version.version,
status: version.status,
stand: version.geaendertAm,
aenderungsnotiz: version.aenderungsnotiz,
struktur,
platzhalter: platzhalterDerStruktur(struktur),
};
+2 -2
View File
@@ -1,7 +1,7 @@
import { Prisma, type Vorlage, type VorlagenVersion } from "@/generated/prisma/client";
import { VersionStatus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { StrukturSchema, type Struktur, type StrukturEingabe } from "@/lib/vorlagen-format";
import { stelleBenennen, StrukturSchema, type Struktur, type StrukturEingabe } from "@/lib/vorlagen-format";
/**
* Vorlagen und ihre Versionierung (#009).
@@ -376,7 +376,7 @@ export async function entwurfSpeichern(
if (!gelesen.success) {
const erster = gelesen.error.issues[0];
throw new VorlagenFehler(
erster ? `${erster.path.join(".")}: ${erster.message}` : "Der Aufbau ist unvollständig.",
erster ? `${stelleBenennen(erster.path)}${erster.message}` : "Der Aufbau ist unvollständig.",
);
}
const struktur = gelesen.data;