diff --git a/messages/de.json b/messages/de.json index c161c74..150615c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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." } } \ No newline at end of file diff --git a/messages/en.json b/messages/en.json index eb52852..a651060 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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." } } \ No newline at end of file diff --git a/src/app/(app)/policies/[code]/edit/page.tsx b/src/app/(app)/policies/[code]/edit/page.tsx index 17905e7..9fc56c1 100644 --- a/src/app/(app)/policies/[code]/edit/page.tsx +++ b/src/app/(app)/policies/[code]/edit/page.tsx @@ -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 {doc.code} · {doc.title} · {POLICY_TYPE_LABEL[doc.type]} + {/* Modus-Umschalter: Standard (Variablen) ↔ Experten (Rohtext) */} +
+ {t("modeStandard")} + {t("modeExpert")} +
+ + {expert ? ( +
+ {/* Rohtext-Editor mit Werkzeugleiste */} +
+
+

{t("template")}

+ +
+ ({ key: v.key, title: v.title }))} + docs={allDocs} + /> +

{t("expertHint")}

+
+ {/* Vorschau (aktualisiert beim Speichern) */} +
+

{t("preview")}

+
+ {infoMd && ( +
+ {t("docInfo")} +
+
+ )} +
+
+
+
+ ) : (
{/* Gerendertes Dokument */}
@@ -150,6 +198,7 @@ export default async function PolicyEditPage({ params }: { params: Promise<{ cod
+ )} ); } diff --git a/src/components/policy-expert-editor.tsx b/src/components/policy-expert-editor.tsx new file mode 100644 index 0000000..6d51e96 --- /dev/null +++ b/src/components/policy-expert-editor.tsx @@ -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; + initialMarkdown: string; + variables: { key: string; title: string }[]; + docs: { code: string; title: string }[]; +}) { + const ref = useRef(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 ( +
+ {/* Werkzeugleiste */} +
+ + + + +