Richtlinien: Lesemodus als eigene Seite + Bearbeitungsmodus (§8)

Auf Wunsch: Dokumente öffnen nicht mehr als Popup, sondern als eigene Seite
mit Zurück-Navigation.

- Neue Routen /policies/[code] (Lesen) und /policies/[code]/edit (Bearbeiten);
  Popup-Modals durch PolicyReadView/PolicyRegisterView ersetzt. Alle Deeplinks
  ({{LINK}}, Coverage, Bibliothek, Register-Callouts) zeigen auf /policies/CODE.
- Detailseite: „← Zurück zur Bibliothek", Titel/Status/Version, Control-Chips,
  „Bearbeiten"-Button (nur Vorlagen-Dokumente, policy:write).
- Bearbeitungsmodus für Leitlinie/Richtlinien/Verfahren:
  * Vorlagen-Markdown-Editor (Textarea) mit Live-Vorschau (Lesemodus),
    Speichern via updatePolicyTemplate.
  * Dokument-bezogene Variablen (§8 Variablen-Scoping): nur die im Dokument
    vorkommenden Variablen — inkl. über BL-Referenzen gebundene — einzeln
    editierbar; Änderung propagiert zentral in alle Dokumente (eine Pflegestelle).
- Register-Actions revalidieren jetzt /policies per layout (Detailseiten aktuell).

Verifiziert: R08 als Seite (kein Modal), Editor mit 23 dokument-bezogenen
Variablen; PW_MIN_LENGTH 12→14 propagiert in R08 und Handbuch; Template-Speichern
mit Redirect zur Ansicht; zurückgesetzt.

Hinweis: Der Vier-Augen-Freigabe-Workflow mit Versionierung/Diff (§8) folgt als
nächster Schritt — aktuell speichert der Editor direkt (mit Audit-Log).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 16:29:18 +02:00
co-authored by Claude Opus 4.8
parent e2b6dee788
commit 094229011d
9 changed files with 365 additions and 163 deletions
+130
View File
@@ -0,0 +1,130 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ArrowLeft } from "lucide-react";
import { requireSession } from "@/server/auth";
import { dbForTenant } from "@/server/db";
import { hasPermission, requirePermission } from "@/server/rbac";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { buildContext, renderPolicyHtml } from "@/lib/policy-render";
import { POLICY_TYPE_LABEL } from "@/components/policy-modals";
import { updatePolicyTemplate, updatePolicyVariable } from "@/server/actions/policies";
const TEMPLATE_TYPES = new Set(["LEITLINIE", "RICHTLINIE", "VERFAHREN"]);
/** Ermittelt die im Dokument tatsächlich vorkommenden Variablen (inkl. über BL-Referenzen), §8. */
function docVariableKeys(raw: string, baseline: { blId: string; vorgabe: string }[]): Set<string> {
const keys = new Set<string>();
for (const m of raw.matchAll(/\{\{\s*([A-Z][A-Z0-9_]*)\s*\}\}/g)) keys.add(m[1]);
for (const m of raw.matchAll(/\{\{#if\s+([A-Z][A-Z0-9_]*)\s*\}\}/g)) keys.add(m[1]);
const bls = new Set([...raw.matchAll(/BL-[A-Z]+-\d+/g)].map((m) => m[0]));
for (const bl of bls) {
const p = baseline.find((b) => b.blId === bl);
if (p) for (const m of p.vorgabe.matchAll(/\{\{\s*([A-Z][A-Z0-9_]*)\s*\}\}/g)) keys.add(m[1]);
}
return keys;
}
export default async function PolicyEditPage({
params,
}: {
params: Promise<{ code: 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 decoded = decodeURIComponent(code);
if (!hasPermission(session, "policy:write")) redirect(`/policies/${code}`);
const doc = await db.policyDocument.findFirst({ where: { code: decoded } });
if (!doc) notFound();
if (!TEMPLATE_TYPES.has(doc.type)) redirect(`/policies/${code}`);
const [variables, baseline] = await Promise.all([
db.policyVariable.findMany({ orderBy: { orderIdx: "asc" } }),
db.policyBaselineParam.findMany(),
]);
const usedKeys = docVariableKeys(doc.rawMarkdown, baseline);
const docVars = variables.filter((v) => usedKeys.has(v.key));
const ctx = buildContext(variables);
const previewHtml = renderPolicyHtml(doc.rawMarkdown, ctx, { readMode: true, stripBaseline: doc.code !== "BASELINE" });
const FORM = "policy-template";
return (
<main className="flex-1 p-6">
<Link href={`/policies/${code}`} className="inline-flex items-center gap-1.5 text-[12.5px] font-semibold text-muted-foreground hover:text-foreground">
<ArrowLeft className="size-4" /> {t("backToDoc")}
</Link>
<div className="mt-3 mb-5 flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="font-heading text-2xl font-bold">{t("editTitle")}</h1>
<p className="mt-0.5 text-[12.5px] text-muted-foreground">{doc.code} · {doc.title} · {POLICY_TYPE_LABEL[doc.type]}</p>
</div>
<div className="flex gap-2">
<Button variant="outline" nativeButton={false} render={<Link href={`/policies/${code}`} />}>{tc("cancel")}</Button>
<Button type="submit" form={FORM}>{tc("save")}</Button>
</div>
</div>
<p className="mb-4 rounded-xl border border-[var(--band-brd)] bg-[var(--band)] px-4 py-2.5 text-[12px] text-[var(--band-text)]">
{t("editHint")}
</p>
<div className="grid gap-5 lg:grid-cols-2">
{/* Vorlage bearbeiten */}
<div>
<p className="mb-2 font-heading text-sm font-semibold">{t("template")}</p>
<form id={FORM} action={updatePolicyTemplate.bind(null, decoded)}>
<Textarea
name="rawMarkdown"
defaultValue={doc.rawMarkdown}
rows={30}
spellCheck={false}
className="w-full font-mono text-[12px] leading-relaxed"
/>
</form>
{/* Dokument-Variablen (nur die hier vorkommenden — eine Pflegestelle, §8) */}
<div className="mt-5">
<p className="mb-1 font-heading text-sm font-semibold">{t("docVariables")}</p>
<p className="mb-2 text-[12px] text-muted-foreground">{t("docVariablesHint")}</p>
<div className="space-y-2">
{docVars.length === 0 && <p className="text-[12.5px] text-muted-foreground">{tc("none")}</p>}
{docVars.map((v) => (
<form key={v.key} action={updatePolicyVariable.bind(null, v.key)} className="flex items-center gap-2">
<label className="w-44 shrink-0 truncate text-[12px] text-muted-foreground" title={`${v.key}${v.title}`}>{v.title}</label>
{v.kind === "boolean" ? (
<select name="value" defaultValue={v.value} className="h-8 flex-1 rounded-md border border-input bg-transparent px-2 text-[12.5px]">
<option value="true">{t("flagOn")}</option>
<option value="false">{t("flagOff")}</option>
</select>
) : (
<Input name="value" defaultValue={v.value} className="h-8 flex-1 text-[12.5px]" />
)}
<Button type="submit" variant="secondary" size="sm">{tc("save")}</Button>
</form>
))}
</div>
</div>
</div>
{/* Vorschau */}
<div>
<p className="mb-2 font-heading text-sm font-semibold">{t("preview")}</p>
<div className="shadow-card max-h-[75vh] overflow-y-auto rounded-2xl border bg-card p-6">
<article className="policy-prose" dangerouslySetInnerHTML={{ __html: previewHtml }} />
</div>
</div>
</div>
</main>
);
}