Files
certvia/src/app/(app)/policies/[code]/edit/page.tsx
T
msolarczekandClaude Opus 4.8 92c184dfc6 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>
2026-07-07 16:56:16 +02:00

205 lines
11 KiB
TypeScript

import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
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 { 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 { PolicyExpertEditor } from "@/components/policy-expert-editor";
import { approvePolicy, rejectPolicy, submitForApproval, updatePolicyTemplate, updateScopedVariables } from "@/server/actions/policies";
const TEMPLATE_TYPES = new Set(["LEITLINIE", "RICHTLINIE", "VERFAHREN"]);
/** 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]);
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,
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}`);
const doc = await db.policyDocument.findFirst({ where: { code: decoded } });
if (!doc) notFound();
if (!TEMPLATE_TYPES.has(doc.type)) redirect(`/policies/${code}`);
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 ?? "—";
const usedKeys = docVariableKeys(doc.rawMarkdown, baseline);
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 { 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 canApprove = hasPermission(session, "policy:approve");
const isSubmitter = doc.submittedBy === session.user.id;
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 gap-2">
<h1 className="font-heading text-2xl font-bold">{t("editTitle")}</h1>
<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>
{/* 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">
{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>
{/* 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 (§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) => (
<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>
{docVars.length > 0 && <Button type="submit" size="sm" className="mt-4 w-full justify-center">{t("saveVariables")}</Button>}
</form>
</div>
</div>
)}
</main>
);
}