Richtlinien-Editor: Experten-Modus (Rohtext) mit Einfüge-Hilfen & Auto-Variablen

Ergänzung zum variablenbasierten Standard-Bearbeitungsmodus:

- Modus-Umschalter „Standard (Variablen) ↔ Experten-Modus" auf /policies/[code]/edit.
- Experten-Modus (Client-Editor policy-expert-editor.tsx): voller Vorlagen-/Markdown-
  Editor mit Formatierungsleiste (fett/kursiv/Überschrift/Liste/Tabelle),
  Einfüge-Dropdowns für Variablen und Dokument-/Register-Verweise ({{LINK:CODE}},
  Deep-Links {{LINK:R08#4.1.2}}), „Neue Variable"-Feld; Live-Vorschau (bei Speichern).
- Beim Speichern werden neue {{VARIABLEN}} automatisch als PolicyVariable angelegt
  (danach im Standard-Modus pflegbar).

Verifiziert: Umschalter, Editor + Toolbar, „Neue Variable" fügt {{STANDORT_WERK}}
ein, Speichern legt die Variable automatisch an; Vorschau rendert.

Zu Bildern/KI-Formulierungshilfe: siehe Antwort — Bilder brauchen Storage/Upload,
KI-Hilfe braucht Anthropic-API-Anbindung (beides als eigener Schritt sinnvoll).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 16:56:16 +02:00
co-authored by Claude Opus 4.8
parent 3cffd3f32e
commit 92c184dfc6
5 changed files with 204 additions and 7 deletions
+4 -1
View File
@@ -561,6 +561,9 @@
"approvedBy": "Freigegeben von", "approvedBy": "Freigegeben von",
"resubmit": "Erneut zur Freigabe einreichen", "resubmit": "Erneut zur Freigabe einreichen",
"approvalNote": "Vier-Augen: Freigebender ≠ Autor. Versionierung/Diff folgt.", "approvalNote": "Vier-Augen: Freigebender ≠ Autor. Versionierung/Diff folgt.",
"saveVariables": "Variablen speichern" "saveVariables": "Variablen speichern",
"modeStandard": "Standard (Variablen)",
"modeExpert": "Experten-Modus",
"expertHint": "Voller Vorlagen-Editor: Text, Formatierung, Variablen, Deep-Links & Referenzen. Neue Variablen werden beim Speichern angelegt und sind danach im Standard-Modus pflegbar."
} }
} }
+4 -1
View File
@@ -561,6 +561,9 @@
"approvedBy": "Approved by", "approvedBy": "Approved by",
"resubmit": "Resubmit for approval", "resubmit": "Resubmit for approval",
"approvalNote": "Four-eyes: approver ≠ author. Versioning/diff to follow.", "approvalNote": "Four-eyes: approver ≠ author. Versioning/diff to follow.",
"saveVariables": "Save variables" "saveVariables": "Save variables",
"modeStandard": "Standard (variables)",
"modeExpert": "Expert mode",
"expertHint": "Full template editor: text, formatting, variables, deep links & references. New variables are created on save and can then be maintained in standard mode."
} }
} }
+52 -3
View File
@@ -10,7 +10,8 @@ import { Input } from "@/components/ui/input";
import { Pill } from "@/components/mockup-ui"; import { Pill } from "@/components/mockup-ui";
import { buildContext, renderPolicyHtml, splitPolicyDoc } from "@/lib/policy-render"; import { buildContext, renderPolicyHtml, splitPolicyDoc } from "@/lib/policy-render";
import { POLICY_STATUS_LABEL, POLICY_STATUS_TONE, POLICY_TYPE_LABEL } from "@/components/policy-modals"; import { POLICY_STATUS_LABEL, POLICY_STATUS_TONE, POLICY_TYPE_LABEL } from "@/components/policy-modals";
import { approvePolicy, rejectPolicy, submitForApproval, updateScopedVariables } from "@/server/actions/policies"; import { PolicyExpertEditor } from "@/components/policy-expert-editor";
import { approvePolicy, rejectPolicy, submitForApproval, updatePolicyTemplate, updateScopedVariables } from "@/server/actions/policies";
const TEMPLATE_TYPES = new Set(["LEITLINIE", "RICHTLINIE", "VERFAHREN"]); const TEMPLATE_TYPES = new Set(["LEITLINIE", "RICHTLINIE", "VERFAHREN"]);
@@ -26,13 +27,21 @@ function docVariableKeys(raw: string, baseline: { blId: string; vorgabe: string
return keys; return keys;
} }
export default async function PolicyEditPage({ params }: { params: Promise<{ code: string }> }) { export default async function PolicyEditPage({
params,
searchParams,
}: {
params: Promise<{ code: string }>;
searchParams: Promise<{ expert?: string }>;
}) {
const session = await requireSession(); const session = await requireSession();
requirePermission(session, "policy:read"); requirePermission(session, "policy:read");
const t = await getTranslations("policies"); const t = await getTranslations("policies");
const tc = await getTranslations("common"); const tc = await getTranslations("common");
const db = dbForTenant(session.user.tenantId); const db = dbForTenant(session.user.tenantId);
const { code } = await params; const { code } = await params;
const { expert: expertParam } = await searchParams;
const expert = expertParam === "1";
const decoded = decodeURIComponent(code); const decoded = decodeURIComponent(code);
if (!hasPermission(session, "policy:write")) redirect(`/policies/${code}`); if (!hasPermission(session, "policy:write")) redirect(`/policies/${code}`);
@@ -41,10 +50,11 @@ export default async function PolicyEditPage({ params }: { params: Promise<{ cod
if (!doc) notFound(); if (!doc) notFound();
if (!TEMPLATE_TYPES.has(doc.type)) redirect(`/policies/${code}`); if (!TEMPLATE_TYPES.has(doc.type)) redirect(`/policies/${code}`);
const [variables, baseline, users] = await Promise.all([ const [variables, baseline, users, allDocs] = await Promise.all([
db.policyVariable.findMany({ orderBy: { orderIdx: "asc" } }), db.policyVariable.findMany({ orderBy: { orderIdx: "asc" } }),
db.policyBaselineParam.findMany(), db.policyBaselineParam.findMany(),
db.user.findMany({ select: { id: true, name: true } }), db.user.findMany({ select: { id: true, name: true } }),
db.policyDocument.findMany({ select: { code: true, title: true }, orderBy: { orderIdx: "asc" } }),
]); ]);
const userName = (id: string | null) => users.find((u) => u.id === id)?.name ?? id ?? "—"; const userName = (id: string | null) => users.find((u) => u.id === id)?.name ?? id ?? "—";
@@ -73,6 +83,44 @@ export default async function PolicyEditPage({ params }: { params: Promise<{ cod
<span className="text-[12.5px] text-muted-foreground">{doc.code} · {doc.title} · {POLICY_TYPE_LABEL[doc.type]}</span> <span className="text-[12.5px] text-muted-foreground">{doc.code} · {doc.title} · {POLICY_TYPE_LABEL[doc.type]}</span>
</div> </div>
{/* Modus-Umschalter: Standard (Variablen) ↔ Experten (Rohtext) */}
<div className="mb-5 inline-flex rounded-lg border bg-card p-1 text-[12.5px] font-semibold">
<Link href={`/policies/${code}/edit`} className={!expert ? "rounded-md bg-[var(--sidebar-accent)] px-3 py-1.5 text-[var(--primary)]" : "px-3 py-1.5 text-muted-foreground hover:text-foreground"}>{t("modeStandard")}</Link>
<Link href={`/policies/${code}/edit?expert=1`} className={expert ? "rounded-md bg-[var(--sidebar-accent)] px-3 py-1.5 text-[var(--primary)]" : "px-3 py-1.5 text-muted-foreground hover:text-foreground"}>{t("modeExpert")}</Link>
</div>
{expert ? (
<div className="grid gap-5 lg:grid-cols-[1.4fr_1fr]">
{/* Rohtext-Editor mit Werkzeugleiste */}
<div>
<div className="mb-3 flex items-center justify-between">
<p className="font-heading text-sm font-semibold">{t("template")}</p>
<Button type="submit" form="policy-template" size="sm">{tc("save")}</Button>
</div>
<PolicyExpertEditor
formId="policy-template"
saveAction={updatePolicyTemplate.bind(null, decoded)}
initialMarkdown={doc.rawMarkdown}
variables={variables.map((v) => ({ key: v.key, title: v.title }))}
docs={allDocs}
/>
<p className="mt-2 text-[11.5px] text-muted-foreground">{t("expertHint")}</p>
</div>
{/* Vorschau (aktualisiert beim Speichern) */}
<div>
<p className="mb-3 font-heading text-sm font-semibold">{t("preview")}</p>
<div className="shadow-card max-h-[80vh] overflow-y-auto rounded-2xl border bg-card p-6">
{infoMd && (
<details className="mb-4 rounded-xl border bg-[var(--surface-soft)]">
<summary className="cursor-pointer list-none px-4 py-2.5 text-[12.5px] font-semibold text-muted-foreground select-none hover:text-foreground [&::-webkit-details-marker]:hidden">{t("docInfo")}</summary>
<div className="policy-prose border-t px-4 py-2" dangerouslySetInnerHTML={{ __html: infoHtml }} />
</details>
)}
<article className="policy-prose" dangerouslySetInnerHTML={{ __html: bodyHtml }} />
</div>
</div>
</div>
) : (
<div className="grid gap-5 lg:grid-cols-[1fr_300px]"> <div className="grid gap-5 lg:grid-cols-[1fr_300px]">
{/* Gerendertes Dokument */} {/* Gerendertes Dokument */}
<div className="shadow-card rounded-2xl border bg-card p-6"> <div className="shadow-card rounded-2xl border bg-card p-6">
@@ -150,6 +198,7 @@ export default async function PolicyEditPage({ params }: { params: Promise<{ cod
</form> </form>
</div> </div>
</div> </div>
)}
</main> </main>
); );
} }
+126
View File
@@ -0,0 +1,126 @@
"use client";
import { useRef, useState } from "react";
import { Bold, Italic, Heading, List, Table, Link2, Plus } from "lucide-react";
/**
* Experten-Editor (Client): voller Markdown-/Vorlagen-Editor mit Formatierungs-
* leiste und Einfüge-Hilfen für Variablen, Dokument-Links und Control-Verweise.
* Speichert über die übergebene Server-Action; fehlende {{VARIABLEN}} werden
* beim Speichern serverseitig automatisch angelegt.
*/
export function PolicyExpertEditor({
formId,
saveAction,
initialMarkdown,
variables,
docs,
}: {
formId: string;
saveAction: (formData: FormData) => void | Promise<void>;
initialMarkdown: string;
variables: { key: string; title: string }[];
docs: { code: string; title: string }[];
}) {
const ref = useRef<HTMLTextAreaElement>(null);
const [value, setValue] = useState(initialMarkdown);
const [newVar, setNewVar] = useState("");
function apply(fn: (sel: string, before: string) => { text: string; caret?: number }) {
const ta = ref.current;
if (!ta) return;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const sel = value.slice(start, end);
const before = value.slice(0, start);
const { text } = fn(sel, before);
const next = before + text + value.slice(end);
setValue(next);
requestAnimationFrame(() => {
ta.focus();
const pos = start + text.length;
ta.setSelectionRange(pos, pos);
});
}
const wrap = (b: string, a: string, placeholder: string) =>
apply((sel) => ({ text: `${b}${sel || placeholder}${a}` }));
const insert = (t: string) => apply(() => ({ text: t }));
const insertVar = (key: string) => key && insert(`{{${key}}}`);
const addNewVar = () => {
const k = newVar.trim().toUpperCase().replace(/[^A-Z0-9_]/g, "_");
if (k) {
insert(`{{${k}}}`);
setNewVar("");
}
};
const btn = "inline-flex h-8 items-center gap-1.5 rounded-md border border-input bg-transparent px-2.5 text-[12px] font-medium text-muted-foreground hover:bg-muted hover:text-foreground";
const sel = "h-8 rounded-md border border-input bg-transparent px-2 text-[12px] text-muted-foreground";
return (
<div>
{/* Werkzeugleiste */}
<div className="mb-2 flex flex-wrap items-center gap-1.5">
<button type="button" className={btn} onClick={() => wrap("**", "**", "fett")} title="Fett"><Bold className="size-3.5" /></button>
<button type="button" className={btn} onClick={() => wrap("_", "_", "kursiv")} title="Kursiv"><Italic className="size-3.5" /></button>
<button type="button" className={btn} onClick={() => apply((sel) => ({ text: `\n## ${sel || "Überschrift"}\n` }))} title="Überschrift"><Heading className="size-3.5" /></button>
<button type="button" className={btn} onClick={() => apply((sel) => ({ text: `\n- ${sel || "Punkt"}\n` }))} title="Liste"><List className="size-3.5" /></button>
<button type="button" className={btn} onClick={() => insert("\n| Spalte A | Spalte B |\n|----------|----------|\n| Wert | Wert |\n")} title="Tabelle"><Table className="size-3.5" /></button>
<span className="mx-1 h-5 w-px bg-border" />
{/* Variablen einfügen */}
<select className={sel} defaultValue="" onChange={(e) => { insertVar(e.target.value); e.currentTarget.value = ""; }} title="Variable einfügen">
<option value="" disabled>Variable einfügen</option>
{variables.map((v) => (
<option key={v.key} value={v.key}>{v.key} {v.title}</option>
))}
</select>
{/* Dokument-Link / Control-Referenz einfügen */}
<select className={sel} defaultValue="" onChange={(e) => { if (e.target.value) insert(`{{LINK:${e.target.value}}}`); e.currentTarget.value = ""; }} title="Verweis einfügen">
<option value="" disabled>Verweis einfügen</option>
<optgroup label="Dokumente & Register">
{docs.map((d) => (
<option key={d.code} value={d.code}>{d.code} {d.title}</option>
))}
</optgroup>
<optgroup label="Zentrale Verweise">
<option value="NACHWEISREGISTER">Nachweisregister</option>
<option value="ISA_MAPPING">ISA-Mapping-Matrix</option>
<option value="BASELINE">Technische Baseline</option>
</optgroup>
</select>
<span className="inline-flex items-center gap-1">
<input
value={newVar}
onChange={(e) => setNewVar(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addNewVar(); } }}
placeholder="Neue Variable"
className="h-8 w-28 rounded-md border border-input bg-transparent px-2 text-[12px]"
/>
<button type="button" className={btn} onClick={addNewVar} title="Neue Variable einfügen"><Plus className="size-3.5" /></button>
</span>
</div>
<p className="mb-2 flex items-center gap-1.5 text-[11.5px] text-muted-foreground">
<Link2 className="size-3.5" /> Deep-Link-Syntax: <code className="rounded bg-[var(--elevated)] px-1">{"{{LINK:R08#4.1.2}}"}</code> verweist auf ein Control. Neue Variablen werden beim Speichern angelegt.
</p>
<form id={formId} action={saveAction}>
<textarea
ref={ref}
name="rawMarkdown"
value={value}
onChange={(e) => setValue(e.target.value)}
spellCheck={false}
rows={32}
className="w-full rounded-xl border border-input bg-transparent p-3 font-mono text-[12px] leading-relaxed outline-none focus:border-[var(--primary)]"
/>
</form>
</div>
);
}
+18 -2
View File
@@ -126,16 +126,32 @@ export async function updateRiskClass(id: string, formData: FormData) {
/* ── Bearbeitungsmodus Richtlinien/Verfahren ── */ /* ── Bearbeitungsmodus Richtlinien/Verfahren ── */
/** Vorlagen-Markdown eines Dokuments speichern. */ /** Vorlagen-Markdown eines Dokuments speichern (Experten-Modus). */
export async function updatePolicyTemplate(code: string, formData: FormData) { export async function updatePolicyTemplate(code: string, formData: FormData) {
const { session, db } = await guard(); const { session, db } = await guard();
const doc = await db.policyDocument.findFirst({ where: { code } }); const doc = await db.policyDocument.findFirst({ where: { code } });
if (!doc) throw new Error("Dokument nicht gefunden"); if (!doc) throw new Error("Dokument nicht gefunden");
const rawMarkdown = String(formData.get("rawMarkdown") ?? ""); const rawMarkdown = String(formData.get("rawMarkdown") ?? "");
await db.policyDocument.update({ where: { id: doc.id }, data: { rawMarkdown } }); await db.policyDocument.update({ where: { id: doc.id }, data: { rawMarkdown } });
// Fehlende {{VARIABLEN}} automatisch anlegen (Experten-Modus: neue Variablen)
const referenced = new Set([...rawMarkdown.matchAll(/\{\{\s*([A-Z][A-Z0-9_]*)\s*\}\}/g)].map((m) => m[1]));
if (referenced.size) {
const existing = new Set((await db.policyVariable.findMany({ select: { key: true } })).map((v) => v.key));
const last = await db.policyVariable.aggregate({ _max: { orderIdx: true } });
let idx = (last._max.orderIdx ?? 0) + 1;
for (const key of referenced) {
if (existing.has(key)) continue;
const isFlag = key.startsWith("FLAG_");
await db.policyVariable.create({
data: { tenantId: session.user.tenantId, key, title: key, kind: isFlag ? "boolean" : "string", groupName: "Eigene", value: isFlag ? "false" : "", orderIdx: idx++ },
});
}
}
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "policy_document", entityId: doc.id }); await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "policy_document", entityId: doc.id });
revalidatePath("/policies", "layout"); revalidatePath("/policies", "layout");
redirect(`/policies/${encodeURIComponent(code)}`); redirect(`/policies/${encodeURIComponent(code)}/edit?expert=1`);
} }
/** Eine dokument-bezogene Variable ändern (eine Pflegestelle, §7/§8) — propagiert in alle Dokumente. */ /** Eine dokument-bezogene Variable ändern (eine Pflegestelle, §7/§8) — propagiert in alle Dokumente. */