From 3cffd3f32eb3729128a46c1200ed7f179781f166 Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 7 Jul 2026 16:45:58 +0200 Subject: [PATCH] Richtlinien-Bearbeitungsmodus am Mockup ausgerichtet: Variablen + Vier-Augen-Freigabe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- messages/de.json | 14 +- messages/en.json | 14 +- .../migration.sql | 5 + prisma/schema.prisma | 4 + src/app/(app)/policies/[code]/edit/page.tsx | 165 ++++++++++-------- src/server/actions/policies.ts | 60 +++++++ 6 files changed, 190 insertions(+), 72 deletions(-) create mode 100644 prisma/migrations/20260707163523_policy_approval/migration.sql diff --git a/messages/de.json b/messages/de.json index 1ec6d5b..c161c74 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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" } } \ No newline at end of file diff --git a/messages/en.json b/messages/en.json index 5bbf7b5..eb52852 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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" } } \ No newline at end of file diff --git a/prisma/migrations/20260707163523_policy_approval/migration.sql b/prisma/migrations/20260707163523_policy_approval/migration.sql new file mode 100644 index 0000000..8e8145e --- /dev/null +++ b/prisma/migrations/20260707163523_policy_approval/migration.sql @@ -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; + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b2c5fd3..061700a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") diff --git a/src/app/(app)/policies/[code]/edit/page.tsx b/src/app/(app)/policies/[code]/edit/page.tsx index b5a36fc..17905e7 100644 --- a/src/app/(app)/policies/[code]/edit/page.tsx +++ b/src/app/(app)/policies/[code]/edit/page.tsx @@ -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 { const keys = new Set(); 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 (
@@ -64,65 +67,87 @@ export default async function PolicyEditPage({ {t("backToDoc")} -
-
-

{t("editTitle")}

-

{doc.code} · {doc.title} · {POLICY_TYPE_LABEL[doc.type]}

-
-
- - -
+
+

{t("editTitle")}

+ {POLICY_STATUS_LABEL[doc.status]} + {doc.code} · {doc.title} · {POLICY_TYPE_LABEL[doc.type]}
-

- {t("editHint")} -

- -
- {/* Vorlage bearbeiten */} -
-

{t("template")}

-
-