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:
+4
-1
@@ -561,6 +561,9 @@
|
||||
"approvedBy": "Freigegeben von",
|
||||
"resubmit": "Erneut zur Freigabe einreichen",
|
||||
"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
@@ -561,6 +561,9 @@
|
||||
"approvedBy": "Approved by",
|
||||
"resubmit": "Resubmit for approval",
|
||||
"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."
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,8 @@ import { Input } from "@/components/ui/input";
|
||||
import { Pill } from "@/components/mockup-ui";
|
||||
import { buildContext, renderPolicyHtml, splitPolicyDoc } from "@/lib/policy-render";
|
||||
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"]);
|
||||
|
||||
@@ -26,13 +27,21 @@ function docVariableKeys(raw: string, baseline: { blId: string; vorgabe: string
|
||||
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();
|
||||
requirePermission(session, "policy:read");
|
||||
const t = await getTranslations("policies");
|
||||
const tc = await getTranslations("common");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const { code } = await params;
|
||||
const { expert: expertParam } = await searchParams;
|
||||
const expert = expertParam === "1";
|
||||
const decoded = decodeURIComponent(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 (!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.policyBaselineParam.findMany(),
|
||||
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 ?? "—";
|
||||
|
||||
@@ -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>
|
||||
</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]">
|
||||
{/* Gerendertes Dokument */}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -126,16 +126,32 @@ export async function updateRiskClass(id: string, formData: FormData) {
|
||||
|
||||
/* ── Bearbeitungsmodus Richtlinien/Verfahren ── */
|
||||
|
||||
/** Vorlagen-Markdown eines Dokuments speichern. */
|
||||
/** Vorlagen-Markdown eines Dokuments speichern (Experten-Modus). */
|
||||
export async function updatePolicyTemplate(code: string, formData: FormData) {
|
||||
const { session, db } = await guard();
|
||||
const doc = await db.policyDocument.findFirst({ where: { code } });
|
||||
if (!doc) throw new Error("Dokument nicht gefunden");
|
||||
const rawMarkdown = String(formData.get("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 });
|
||||
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. */
|
||||
|
||||
Reference in New Issue
Block a user