Richtlinien-Bearbeitungsmodus am Mockup ausgerichtet: Variablen + Vier-Augen-Freigabe
Korrektur nach Mockup-Abgleich: Der Bearbeitungsmodus editiert NICHT den Rohtext, sondern (wie im Mockup und Akzeptanzkriterium „im Bearbeiten nur dokumentbezogene Variablen") die dokument-bezogenen Variablen — freie Blocktext-Änderungen sind dem KI-Wizard vorbehalten. - Edit-Seite neu: gerendertes Dokument links, rechts Karte „Dokument-Variablen" (nur die im Dokument vorkommenden, inkl. BL-gebundene; zentrale Pflegestelle §8) + „Freigabe"-Leiste. Rohtext-Textarea entfernt. - Freigabe-Workflow (Vier-Augen, §8): Status Entwurf → In Freigabe → Freigegeben; submitForApproval/approvePolicy/rejectPolicy; Freigabe erfordert policy:approve UND Freigebender ≠ Einreichender; Ablehnung mit Begründung → zurück auf Entwurf; alles im Audit-Log. Neue Felder submitted_by/approved_by/approved_at. - updateScopedVariables: gebündeltes Speichern der Dokument-Variablen. Verifiziert: Editor zeigt Variablen-Karte + Freigabe-Leiste (kein Rohtext); R08 „Erneut einreichen" → In Freigabe; Vier-Augen-Sperre greift (Anna = Autor). Offen (nächste Ausbaustufe): echte Versionierung mit Entwurf-neben-Freigegeben + block-/klauselgenauer Diff; KI-Wizard für Blocktext; DOCX/PDF-Export; Word-Upload. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+13
-1
@@ -549,6 +549,18 @@
|
||||
"docVariablesHint": "Nur die in diesem Dokument vorkommenden Variablen. Änderungen wirken in allen Dokumenten (eine Pflegestelle).",
|
||||
"flagOn": "aktiv (ja)",
|
||||
"flagOff": "inaktiv (nein)",
|
||||
"preview": "Vorschau (Lesemodus)"
|
||||
"preview": "Vorschau (Lesemodus)",
|
||||
"approval": "Freigabe",
|
||||
"submitForApproval": "Zur Freigabe einreichen",
|
||||
"submittedBy": "Eingereicht von",
|
||||
"approve": "Genehmigen (ISB)",
|
||||
"reject": "Ablehnen",
|
||||
"rejectReason": "Begründung der Ablehnung",
|
||||
"fourEyesSelf": "Vier-Augen-Prinzip: Die Freigabe muss durch eine andere Person als den Einreichenden erfolgen.",
|
||||
"fourEyesNoRight": "Freigabe erfordert die Rolle mit Freigaberecht (z. B. ISB).",
|
||||
"approvedBy": "Freigegeben von",
|
||||
"resubmit": "Erneut zur Freigabe einreichen",
|
||||
"approvalNote": "Vier-Augen: Freigebender ≠ Autor. Versionierung/Diff folgt.",
|
||||
"saveVariables": "Variablen speichern"
|
||||
}
|
||||
}
|
||||
+13
-1
@@ -549,6 +549,18 @@
|
||||
"docVariablesHint": "Only the variables used in this document. Changes apply across all documents (single source).",
|
||||
"flagOn": "active (yes)",
|
||||
"flagOff": "inactive (no)",
|
||||
"preview": "Preview (read mode)"
|
||||
"preview": "Preview (read mode)",
|
||||
"approval": "Approval",
|
||||
"submitForApproval": "Submit for approval",
|
||||
"submittedBy": "Submitted by",
|
||||
"approve": "Approve (ISO)",
|
||||
"reject": "Reject",
|
||||
"rejectReason": "Reason for rejection",
|
||||
"fourEyesSelf": "Four-eyes principle: approval must be done by someone other than the submitter.",
|
||||
"fourEyesNoRight": "Approval requires the approver role (e.g. ISO).",
|
||||
"approvedBy": "Approved by",
|
||||
"resubmit": "Resubmit for approval",
|
||||
"approvalNote": "Four-eyes: approver ≠ author. Versioning/diff to follow.",
|
||||
"saveVariables": "Save variables"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "policy_documents" ADD COLUMN "approved_at" TIMESTAMP(3),
|
||||
ADD COLUMN "approved_by" TEXT,
|
||||
ADD COLUMN "submitted_by" TEXT;
|
||||
|
||||
@@ -689,6 +689,10 @@ model PolicyDocument {
|
||||
fulfills String[] @default([]) // bei VA: erfüllte Anforderungs-IDs
|
||||
rawMarkdown String @map("raw_markdown") // Vorlagen-Markdown mit Platzhaltern
|
||||
orderIdx Int @default(0) @map("order_idx")
|
||||
// Freigabe-Workflow (Vier-Augen): wer eingereicht/genehmigt hat
|
||||
submittedBy String? @map("submitted_by")
|
||||
approvedBy String? @map("approved_by")
|
||||
approvedAt DateTime? @map("approved_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
import Link from "next/link";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { ArrowLeft, Check, Send, X } 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";
|
||||
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";
|
||||
|
||||
const TEMPLATE_TYPES = new Set(["LEITLINIE", "RICHTLINIE", "VERFAHREN"]);
|
||||
|
||||
/** Ermittelt die im Dokument tatsächlich vorkommenden Variablen (inkl. über BL-Referenzen), §8. */
|
||||
/** Im Dokument vorkommende Variablen (inkl. über BL-Referenzen gebunden), ohne Feature-Flags. §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);
|
||||
@@ -27,11 +26,7 @@ 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 }: { params: Promise<{ code: string }> }) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "policy:read");
|
||||
const t = await getTranslations("policies");
|
||||
@@ -46,17 +41,25 @@ export default async function PolicyEditPage({
|
||||
if (!doc) notFound();
|
||||
if (!TEMPLATE_TYPES.has(doc.type)) redirect(`/policies/${code}`);
|
||||
|
||||
const [variables, baseline] = await Promise.all([
|
||||
const [variables, baseline, users] = await Promise.all([
|
||||
db.policyVariable.findMany({ orderBy: { orderIdx: "asc" } }),
|
||||
db.policyBaselineParam.findMany(),
|
||||
db.user.findMany({ select: { id: true, name: true } }),
|
||||
]);
|
||||
const userName = (id: string | null) => users.find((u) => u.id === id)?.name ?? id ?? "—";
|
||||
|
||||
const usedKeys = docVariableKeys(doc.rawMarkdown, baseline);
|
||||
const docVars = variables.filter((v) => usedKeys.has(v.key));
|
||||
const docVars = variables.filter((v) => usedKeys.has(v.key) && v.kind !== "boolean");
|
||||
const keysCsv = docVars.map((v) => v.key).join(",");
|
||||
|
||||
const ctx = buildContext(variables);
|
||||
const previewHtml = renderPolicyHtml(doc.rawMarkdown, ctx, { readMode: true, stripBaseline: doc.code !== "BASELINE" });
|
||||
const { infoMd, bodyMd } = splitPolicyDoc(doc.rawMarkdown);
|
||||
const stripBaseline = doc.code !== "BASELINE";
|
||||
const bodyHtml = renderPolicyHtml(bodyMd, ctx, { readMode: true, stripBaseline });
|
||||
const infoHtml = renderPolicyHtml(infoMd, ctx, { readMode: true, stripBaseline: false });
|
||||
|
||||
const FORM = "policy-template";
|
||||
const canApprove = hasPermission(session, "policy:approve");
|
||||
const isSubmitter = doc.submittedBy === session.user.id;
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
@@ -64,65 +67,87 @@ export default async function PolicyEditPage({
|
||||
<ArrowLeft className="size-4" /> {t("backToDoc")}
|
||||
</Link>
|
||||
|
||||
<div className="mt-3 mb-5 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="mt-3 mb-5 flex flex-wrap items-center gap-2">
|
||||
<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>
|
||||
<Pill tone={POLICY_STATUS_TONE[doc.status]}>{POLICY_STATUS_LABEL[doc.status]}</Pill>
|
||||
<span className="text-[12.5px] text-muted-foreground">{doc.code} · {doc.title} · {POLICY_TYPE_LABEL[doc.type]}</span>
|
||||
</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-[1fr_300px]">
|
||||
{/* Gerendertes Dokument */}
|
||||
<div className="shadow-card 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 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"
|
||||
/>
|
||||
{/* Rechte Leiste: Freigabe + Variablen */}
|
||||
<div className="space-y-4">
|
||||
{/* Freigabe-Workflow (Vier-Augen, §8) */}
|
||||
<div className="shadow-card rounded-2xl border bg-card p-4">
|
||||
<p className="font-heading text-sm font-semibold">{t("approval")}</p>
|
||||
<div className="mt-3 space-y-2">
|
||||
{doc.status === "ENTWURF" && (
|
||||
<form action={submitForApproval.bind(null, decoded)}>
|
||||
<Button type="submit" className="w-full justify-center"><Send className="size-4" /> {t("submitForApproval")}</Button>
|
||||
</form>
|
||||
)}
|
||||
{doc.status === "IN_FREIGABE" && (
|
||||
<>
|
||||
<p className="text-[12px] text-muted-foreground">{t("submittedBy")}: <b>{userName(doc.submittedBy)}</b></p>
|
||||
{canApprove && !isSubmitter ? (
|
||||
<>
|
||||
<form action={approvePolicy.bind(null, decoded)}>
|
||||
<Button type="submit" className="w-full justify-center"><Check className="size-4" /> {t("approve")}</Button>
|
||||
</form>
|
||||
<form action={rejectPolicy.bind(null, decoded)} className="space-y-2">
|
||||
<Input name="reason" placeholder={t("rejectReason")} className="h-8" />
|
||||
<Button type="submit" variant="outline" size="sm" className="w-full justify-center"><X className="size-4" /> {t("reject")}</Button>
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
<p className="rounded-lg border border-[var(--band-brd)] bg-[var(--band)] px-3 py-2 text-[12px] text-[var(--band-text)]">
|
||||
{isSubmitter ? t("fourEyesSelf") : t("fourEyesNoRight")}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{doc.status === "FREIGEGEBEN" && (
|
||||
<>
|
||||
<p className="text-[12px] text-muted-foreground">
|
||||
{t("approvedBy")}: <b>{userName(doc.approvedBy)}</b>
|
||||
</p>
|
||||
<form action={submitForApproval.bind(null, decoded)}>
|
||||
<Button type="submit" variant="outline" size="sm" className="w-full justify-center"><Send className="size-4" /> {t("resubmit")}</Button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-3 text-[11px] text-muted-foreground">{t("approvalNote")}</p>
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
{/* Dokument-Variablen (§8 Variablen-Scoping) */}
|
||||
<form action={updateScopedVariables.bind(null, decoded)} className="shadow-card rounded-2xl border bg-card p-4">
|
||||
<input type="hidden" name="keys" value={keysCsv} />
|
||||
<p className="font-heading text-sm font-semibold">{t("docVariables")}</p>
|
||||
<p className="mt-1 mb-3 text-[11.5px] text-muted-foreground">{t("docVariablesHint")}</p>
|
||||
<div className="space-y-2.5">
|
||||
{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 key={v.key}>
|
||||
<label htmlFor={`var_${v.key}`} className="block text-[11.5px] text-muted-foreground" title={v.key}>{v.title}</label>
|
||||
<Input id={`var_${v.key}`} name={`var_${v.key}`} defaultValue={v.value} className="mt-0.5 h-8 text-[12.5px]" />
|
||||
</div>
|
||||
))}
|
||||
</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>
|
||||
{docVars.length > 0 && <Button type="submit" size="sm" className="mt-4 w-full justify-center">{t("saveVariables")}</Button>}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -146,3 +146,63 @@ export async function updatePolicyVariable(key: string, formData: FormData) {
|
||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "policy_variable", after: { key, value } });
|
||||
revalidatePath("/policies", "layout");
|
||||
}
|
||||
|
||||
/**
|
||||
* Dokument-bezogene Variablen gebündelt speichern (Bearbeitungsmodus). Nur die
|
||||
* übergebenen Schlüssel (Feld `keys` = kommagetrennt) werden aus dem Formular
|
||||
* gelesen; Werte gelten zentral für alle Dokumente (eine Pflegestelle, §8).
|
||||
*/
|
||||
export async function updateScopedVariables(code: string, formData: FormData) {
|
||||
const { session, db } = await guard();
|
||||
const keys = String(formData.get("keys") ?? "").split(",").map((k) => k.trim()).filter(Boolean);
|
||||
for (const key of keys) {
|
||||
if (!formData.has(`var_${key}`)) continue;
|
||||
const value = String(formData.get(`var_${key}`) ?? "");
|
||||
await db.policyVariable.updateMany({ where: { key }, data: { value } });
|
||||
}
|
||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "policy_variables", entityId: code, after: { keys } });
|
||||
revalidatePath("/policies", "layout");
|
||||
redirect(`/policies/${encodeURIComponent(code)}/edit`);
|
||||
}
|
||||
|
||||
/* ── Freigabe-Workflow (Vier-Augen, §8) ── */
|
||||
|
||||
export async function submitForApproval(code: string) {
|
||||
const { session, db } = await guard();
|
||||
const doc = await db.policyDocument.findFirst({ where: { code } });
|
||||
if (!doc) throw new Error("Dokument nicht gefunden");
|
||||
await db.policyDocument.update({ where: { id: doc.id }, data: { status: "IN_FREIGABE", submittedBy: session.user.id, approvedBy: null, approvedAt: null } });
|
||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "policy_document", entityId: doc.id, after: { status: "IN_FREIGABE" } });
|
||||
revalidatePath("/policies", "layout");
|
||||
redirect(`/policies/${encodeURIComponent(code)}/edit`);
|
||||
}
|
||||
|
||||
export async function approvePolicy(code: string) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "policy:approve");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const doc = await db.policyDocument.findFirst({ where: { code } });
|
||||
if (!doc) throw new Error("Dokument nicht gefunden");
|
||||
if (doc.status !== "IN_FREIGABE") throw new Error("Dokument ist nicht in Freigabe");
|
||||
// Vier-Augen: Freigebender ≠ Einreichender
|
||||
if (doc.submittedBy && doc.submittedBy === session.user.id) {
|
||||
throw new Error("Vier-Augen-Prinzip: Freigabe muss durch eine andere Person als den Einreichenden erfolgen.");
|
||||
}
|
||||
await db.policyDocument.update({ where: { id: doc.id }, data: { status: "FREIGEGEBEN", approvedBy: session.user.id, approvedAt: new Date() } });
|
||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "policy_document", entityId: doc.id, after: { status: "FREIGEGEBEN" } });
|
||||
revalidatePath("/policies", "layout");
|
||||
redirect(`/policies/${encodeURIComponent(code)}`);
|
||||
}
|
||||
|
||||
export async function rejectPolicy(code: string, formData: FormData) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "policy:approve");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const doc = await db.policyDocument.findFirst({ where: { code } });
|
||||
if (!doc) throw new Error("Dokument nicht gefunden");
|
||||
const reason = str(formData.get("reason"));
|
||||
await db.policyDocument.update({ where: { id: doc.id }, data: { status: "ENTWURF", submittedBy: null } });
|
||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "policy_document", entityId: doc.id, after: { status: "ENTWURF", rejected: true, reason } });
|
||||
revalidatePath("/policies", "layout");
|
||||
redirect(`/policies/${encodeURIComponent(code)}/edit`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user