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:
+13
-1
@@ -537,6 +537,18 @@
|
|||||||
"kpiControlsTrend": "VDA-ISA 2027",
|
"kpiControlsTrend": "VDA-ISA 2027",
|
||||||
"kpiMust": "MUSS-Anforderungen",
|
"kpiMust": "MUSS-Anforderungen",
|
||||||
"kpiShould": "SOLL-Anforderungen",
|
"kpiShould": "SOLL-Anforderungen",
|
||||||
"openFullTable": "Vollständige Tabelle öffnen"
|
"openFullTable": "Vollständige Tabelle öffnen",
|
||||||
|
"backToLibrary": "Zurück zur Bibliothek",
|
||||||
|
"backToDoc": "Zurück zum Dokument",
|
||||||
|
"editableInline": "direkt in der Ansicht bearbeitbar",
|
||||||
|
"edit": "Bearbeiten",
|
||||||
|
"editTitle": "Dokument bearbeiten",
|
||||||
|
"editHint": "Änderungen werden direkt gespeichert. Der Vier-Augen-Freigabe-Workflow mit Versionierung folgt. Variablenwerte gelten zentral für alle Dokumente.",
|
||||||
|
"template": "Vorlage (Markdown)",
|
||||||
|
"docVariables": "Dokument-Variablen",
|
||||||
|
"docVariablesHint": "Nur die in diesem Dokument vorkommenden Variablen. Änderungen wirken in allen Dokumenten (eine Pflegestelle).",
|
||||||
|
"flagOn": "aktiv (ja)",
|
||||||
|
"flagOff": "inaktiv (nein)",
|
||||||
|
"preview": "Vorschau (Lesemodus)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+13
-1
@@ -537,6 +537,18 @@
|
|||||||
"kpiControlsTrend": "VDA-ISA 2027",
|
"kpiControlsTrend": "VDA-ISA 2027",
|
||||||
"kpiMust": "MUST requirements",
|
"kpiMust": "MUST requirements",
|
||||||
"kpiShould": "SHOULD requirements",
|
"kpiShould": "SHOULD requirements",
|
||||||
"openFullTable": "Open full table"
|
"openFullTable": "Open full table",
|
||||||
|
"backToLibrary": "Back to library",
|
||||||
|
"backToDoc": "Back to document",
|
||||||
|
"editableInline": "editable inline in the view",
|
||||||
|
"edit": "Edit",
|
||||||
|
"editTitle": "Edit document",
|
||||||
|
"editHint": "Changes are saved directly. The four-eyes approval workflow with versioning follows. Variable values apply centrally to all documents.",
|
||||||
|
"template": "Template (Markdown)",
|
||||||
|
"docVariables": "Document variables",
|
||||||
|
"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)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { ArrowLeft, Pencil } 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 { Pill } from "@/components/mockup-ui";
|
||||||
|
import {
|
||||||
|
PolicyReadView,
|
||||||
|
POLICY_STATUS_LABEL,
|
||||||
|
POLICY_STATUS_TONE,
|
||||||
|
POLICY_TYPE_LABEL,
|
||||||
|
} from "@/components/policy-modals";
|
||||||
|
import { PolicyRegisterView } from "@/components/policy-registers";
|
||||||
|
|
||||||
|
const REGISTER_CODES = new Set(["CRYPTO", "RISKMATRIX", "CLASSIFICATION", "HANDBUCH"]);
|
||||||
|
// Aus Vorlagen-Markdown gerenderte Dokumente (bearbeitbar über den Editor)
|
||||||
|
const TEMPLATE_TYPES = new Set(["LEITLINIE", "RICHTLINIE", "VERFAHREN"]);
|
||||||
|
|
||||||
|
export default async function PolicyDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ code: string }>;
|
||||||
|
}) {
|
||||||
|
const session = await requireSession();
|
||||||
|
requirePermission(session, "policy:read");
|
||||||
|
const t = await getTranslations("policies");
|
||||||
|
const db = dbForTenant(session.user.tenantId);
|
||||||
|
const { code } = await params;
|
||||||
|
|
||||||
|
const doc = await db.policyDocument.findFirst({ where: { code: decodeURIComponent(code) } });
|
||||||
|
if (!doc) notFound();
|
||||||
|
|
||||||
|
const canWrite = hasPermission(session, "policy:write");
|
||||||
|
const isRegister = REGISTER_CODES.has(doc.code);
|
||||||
|
|
||||||
|
let content: React.ReactNode;
|
||||||
|
if (isRegister) {
|
||||||
|
const data =
|
||||||
|
doc.code === "CRYPTO"
|
||||||
|
? { crypto: await db.cryptoEntry.findMany({ orderBy: { orderIdx: "asc" } }) }
|
||||||
|
: doc.code === "CLASSIFICATION"
|
||||||
|
? {
|
||||||
|
classes: await db.classificationClass.findMany({ orderBy: { orderIdx: "asc" } }),
|
||||||
|
aspects: await db.handlingAspect.findMany({ orderBy: { orderIdx: "asc" } }),
|
||||||
|
rules: await db.handlingRule.findMany(),
|
||||||
|
}
|
||||||
|
: doc.code === "RISKMATRIX"
|
||||||
|
? {
|
||||||
|
riskClasses: await db.riskMatrixClass.findMany({ orderBy: { orderIdx: "asc" } }),
|
||||||
|
ewLevels: await db.riskEwLevel.findMany({ orderBy: { level: "asc" } }),
|
||||||
|
damage: await db.riskDamageDimension.findMany({ orderBy: { orderIdx: "asc" } }),
|
||||||
|
}
|
||||||
|
: { handbook: await db.handbookTopic.findMany({ orderBy: { orderIdx: "asc" } }), variables: await db.policyVariable.findMany({ orderBy: { orderIdx: "asc" } }) };
|
||||||
|
content = <PolicyRegisterView doc={doc} data={data} canWrite={canWrite} />;
|
||||||
|
} else {
|
||||||
|
const [variables, reqs] = await Promise.all([
|
||||||
|
db.policyVariable.findMany({ orderBy: { orderIdx: "asc" } }),
|
||||||
|
db.policyRequirement.findMany({ where: { policyCode: doc.code } }),
|
||||||
|
]);
|
||||||
|
const controls = [...new Set(reqs.map((r) => r.control))].sort();
|
||||||
|
const relatedVas = [...new Set(reqs.flatMap((r) => r.vaCodes))].sort();
|
||||||
|
content = <PolicyReadView data={{ doc, variables, controls, relatedVas }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const showEdit = canWrite && TEMPLATE_TYPES.has(doc.type);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex-1 p-6">
|
||||||
|
<Link href="/policies" className="inline-flex items-center gap-1.5 text-[12.5px] font-semibold text-muted-foreground hover:text-foreground">
|
||||||
|
<ArrowLeft className="size-4" /> {t("backToLibrary")}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="mt-3 mb-5 flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<h1 className="font-heading text-2xl font-bold">{doc.code} · {doc.title}</h1>
|
||||||
|
<Pill tone={POLICY_STATUS_TONE[doc.status]}>{POLICY_STATUS_LABEL[doc.status]}</Pill>
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 text-[12.5px] text-muted-foreground">
|
||||||
|
{POLICY_TYPE_LABEL[doc.type]} · v{doc.version}
|
||||||
|
{isRegister && canWrite ? ` · ${t("editableInline")}` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{showEdit && (
|
||||||
|
<Button nativeButton={false} render={<Link href={`/policies/${doc.code}/edit`} />}>
|
||||||
|
<Pencil className="size-4" /> {t("edit")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="shadow-card rounded-2xl border bg-card p-6">{content}</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,11 +2,9 @@ import Link from "next/link";
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { requireSession } from "@/server/auth";
|
import { requireSession } from "@/server/auth";
|
||||||
import { dbForTenant } from "@/server/db";
|
import { dbForTenant } from "@/server/db";
|
||||||
import { hasPermission, requirePermission } from "@/server/rbac";
|
import { requirePermission } from "@/server/rbac";
|
||||||
import { PageHead, Pill, Tag, KpiCard } from "@/components/mockup-ui";
|
import { PageHead, Pill, Tag, KpiCard } from "@/components/mockup-ui";
|
||||||
import { FilterTabs } from "@/components/filter-tabs";
|
import { FilterTabs } from "@/components/filter-tabs";
|
||||||
import { PolicyReadModal } from "@/components/policy-modals";
|
|
||||||
import { PolicyRegisterModal } from "@/components/policy-registers";
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -64,44 +62,6 @@ export default async function PoliciesPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isCoverage = params.view === "coverage";
|
const isCoverage = params.view === "coverage";
|
||||||
const canWrite = hasPermission(session, "policy:write");
|
|
||||||
const backHref = isCoverage ? "/policies?view=coverage" : "/policies";
|
|
||||||
|
|
||||||
/* ── Modal: Lesemodus (Markdown) oder interaktives Register (§7b) ── */
|
|
||||||
const modalDoc = params.doc ? docs.find((d) => d.code === params.doc) : null;
|
|
||||||
const REGISTER_CODES = new Set(["CRYPTO", "RISKMATRIX", "CLASSIFICATION", "HANDBUCH"]);
|
|
||||||
let modalNode: React.ReactNode = null;
|
|
||||||
if (modalDoc && REGISTER_CODES.has(modalDoc.code)) {
|
|
||||||
const registerData =
|
|
||||||
modalDoc.code === "CRYPTO"
|
|
||||||
? { crypto: await db.cryptoEntry.findMany({ orderBy: { orderIdx: "asc" } }) }
|
|
||||||
: modalDoc.code === "CLASSIFICATION"
|
|
||||||
? {
|
|
||||||
classes: await db.classificationClass.findMany({ orderBy: { orderIdx: "asc" } }),
|
|
||||||
aspects: await db.handlingAspect.findMany({ orderBy: { orderIdx: "asc" } }),
|
|
||||||
rules: await db.handlingRule.findMany(),
|
|
||||||
}
|
|
||||||
: modalDoc.code === "RISKMATRIX"
|
|
||||||
? {
|
|
||||||
riskClasses: await db.riskMatrixClass.findMany({ orderBy: { orderIdx: "asc" } }),
|
|
||||||
ewLevels: await db.riskEwLevel.findMany({ orderBy: { level: "asc" } }),
|
|
||||||
damage: await db.riskDamageDimension.findMany({ orderBy: { orderIdx: "asc" } }),
|
|
||||||
}
|
|
||||||
: { handbook: await db.handbookTopic.findMany({ orderBy: { orderIdx: "asc" } }), variables: await db.policyVariable.findMany({ orderBy: { orderIdx: "asc" } }) };
|
|
||||||
modalNode = <PolicyRegisterModal doc={modalDoc} data={registerData} canWrite={canWrite} backHref={backHref} />;
|
|
||||||
} else if (modalDoc) {
|
|
||||||
modalNode = (
|
|
||||||
<PolicyReadModal
|
|
||||||
data={{
|
|
||||||
doc: modalDoc,
|
|
||||||
variables: await db.policyVariable.findMany({ orderBy: { orderIdx: "asc" } }),
|
|
||||||
controls: [...(byPolicy.get(modalDoc.code)?.controls ?? [])].sort(),
|
|
||||||
relatedVas: [...(byPolicy.get(modalDoc.code)?.vas ?? [])].sort(),
|
|
||||||
}}
|
|
||||||
backHref={backHref}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const head = (
|
const head = (
|
||||||
<PageHead
|
<PageHead
|
||||||
@@ -152,7 +112,7 @@ export default async function PoliciesPage({
|
|||||||
<TableRow key={control}>
|
<TableRow key={control}>
|
||||||
<TableCell className="font-bold">{control}</TableCell>
|
<TableCell className="font-bold">{control}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Link href={`/policies?doc=${e.policy}`} className="font-semibold hover:underline">{e.policy}</Link>
|
<Link href={`/policies/${e.policy}`} className="font-semibold hover:underline">{e.policy}</Link>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{e.must}</TableCell>
|
<TableCell>{e.must}</TableCell>
|
||||||
<TableCell className="text-muted-foreground">{e.should || "–"}</TableCell>
|
<TableCell className="text-muted-foreground">{e.should || "–"}</TableCell>
|
||||||
@@ -162,7 +122,7 @@ export default async function PoliciesPage({
|
|||||||
<span className="text-muted-foreground">–</span>
|
<span className="text-muted-foreground">–</span>
|
||||||
) : (
|
) : (
|
||||||
[...e.vas].sort().map((va) => (
|
[...e.vas].sort().map((va) => (
|
||||||
<Link key={va} href={`/policies?doc=${va}`}>
|
<Link key={va} href={`/policies/${va}`}>
|
||||||
<Pill tone="info">{va}</Pill>
|
<Pill tone="info">{va}</Pill>
|
||||||
</Link>
|
</Link>
|
||||||
))
|
))
|
||||||
@@ -175,7 +135,6 @@ export default async function PoliciesPage({
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
{modalNode}
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -239,7 +198,7 @@ export default async function PoliciesPage({
|
|||||||
<TableRow key={d.id}>
|
<TableRow key={d.id}>
|
||||||
<TableCell className="text-muted-foreground">{d.code}</TableCell>
|
<TableCell className="text-muted-foreground">{d.code}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Link href={`/policies?doc=${d.code}`} className="font-bold hover:underline">{d.title}</Link>
|
<Link href={`/policies/${d.code}`} className="font-bold hover:underline">{d.title}</Link>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell><Tag>{TYPE_LABEL[d.type]}</Tag></TableCell>
|
<TableCell><Tag>{TYPE_LABEL[d.type]}</Tag></TableCell>
|
||||||
<TableCell className="text-muted-foreground">v{d.version}</TableCell>
|
<TableCell className="text-muted-foreground">v{d.version}</TableCell>
|
||||||
@@ -255,8 +214,6 @@ export default async function PoliciesPage({
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{modalNode}
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import type { PolicyDocument, PolicyVariable } from "@prisma/client";
|
import type { PolicyDocument, PolicyVariable } from "@prisma/client";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Pill } from "@/components/mockup-ui";
|
||||||
import { Modal } from "@/components/modal";
|
|
||||||
import { Pill, Tag } from "@/components/mockup-ui";
|
|
||||||
import { buildContext, renderPolicyHtml, splitPolicyDoc } from "@/lib/policy-render";
|
import { buildContext, renderPolicyHtml, splitPolicyDoc } from "@/lib/policy-render";
|
||||||
|
|
||||||
const TYPE_LABEL: Record<string, string> = {
|
export const POLICY_TYPE_LABEL: Record<string, string> = {
|
||||||
LEITLINIE: "Leitlinie",
|
LEITLINIE: "Leitlinie",
|
||||||
RICHTLINIE: "Richtlinie",
|
RICHTLINIE: "Richtlinie",
|
||||||
VERFAHREN: "Verfahren",
|
VERFAHREN: "Verfahren",
|
||||||
@@ -14,13 +12,13 @@ const TYPE_LABEL: Record<string, string> = {
|
|||||||
HANDBUCH: "Handbuch",
|
HANDBUCH: "Handbuch",
|
||||||
EIGENES: "Eigenes",
|
EIGENES: "Eigenes",
|
||||||
};
|
};
|
||||||
const STATUS_TONE: Record<string, "ok" | "info" | "warn" | "mut"> = {
|
export const POLICY_STATUS_TONE: Record<string, "ok" | "info" | "warn" | "mut"> = {
|
||||||
FREIGEGEBEN: "ok",
|
FREIGEGEBEN: "ok",
|
||||||
IN_FREIGABE: "info",
|
IN_FREIGABE: "info",
|
||||||
ENTWURF: "warn",
|
ENTWURF: "warn",
|
||||||
ARCHIVIERT: "mut",
|
ARCHIVIERT: "mut",
|
||||||
};
|
};
|
||||||
const STATUS_LABEL: Record<string, string> = {
|
export const POLICY_STATUS_LABEL: Record<string, string> = {
|
||||||
FREIGEGEBEN: "Freigegeben",
|
FREIGEGEBEN: "Freigegeben",
|
||||||
IN_FREIGABE: "In Freigabe",
|
IN_FREIGABE: "In Freigabe",
|
||||||
ENTWURF: "Entwurf",
|
ENTWURF: "Entwurf",
|
||||||
@@ -46,8 +44,8 @@ export interface PolicyReadData {
|
|||||||
relatedVas: string[];
|
relatedVas: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Lesemodus-Popup: gerendertes Dokument aus der echten Markdown-Vorlage. */
|
/** Lesemodus-Inhalt: gerendertes Dokument aus der echten Markdown-Vorlage (auf eigener Seite). */
|
||||||
export async function PolicyReadModal({ data, backHref }: { data: PolicyReadData; backHref: string }) {
|
export async function PolicyReadView({ data }: { data: PolicyReadData }) {
|
||||||
const t = await getTranslations("policies");
|
const t = await getTranslations("policies");
|
||||||
const { doc, variables, controls, relatedVas } = data;
|
const { doc, variables, controls, relatedVas } = data;
|
||||||
const ctx = buildContext(variables);
|
const ctx = buildContext(variables);
|
||||||
@@ -58,76 +56,61 @@ export async function PolicyReadModal({ data, backHref }: { data: PolicyReadData
|
|||||||
const bodyHtml = renderPolicyHtml(bodyMd, ctx, { readMode: true, stripBaseline });
|
const bodyHtml = renderPolicyHtml(bodyMd, ctx, { readMode: true, stripBaseline });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<div>
|
||||||
title={`${doc.code} · ${doc.title}`}
|
{/* Kontext: Controls / operationalisiert-durch */}
|
||||||
sub={TYPE_LABEL[doc.type]}
|
{(controls.length > 0 || doc.policyCode || relatedVas.length > 0) && (
|
||||||
headerExtra={
|
<div className="mb-4 flex flex-wrap items-center gap-2 text-[12px]">
|
||||||
<span className="flex items-center gap-2">
|
{doc.type === "VERFAHREN" && doc.policyCode && (
|
||||||
<Tag>{TYPE_LABEL[doc.type]}</Tag>
|
<span className="text-muted-foreground">
|
||||||
<Pill tone={STATUS_TONE[doc.status]}>{STATUS_LABEL[doc.status]}</Pill>
|
{t("operationalizes")}{" "}
|
||||||
<span className="text-[12px] text-muted-foreground">v{doc.version}</span>
|
<Link href={`/policies/${doc.policyCode}`} className="font-semibold text-[var(--info)] hover:underline">
|
||||||
</span>
|
{doc.policyCode}
|
||||||
}
|
</Link>
|
||||||
closeHref={backHref}
|
|
||||||
closeLabel={t("close")}
|
|
||||||
footer={<Button nativeButton={false} render={<Link href={backHref} />}>{t("close")}</Button>}
|
|
||||||
>
|
|
||||||
<div className="max-h-[72vh] overflow-y-auto p-5">
|
|
||||||
{/* Kontext: Controls / operationalisiert-durch */}
|
|
||||||
{(controls.length > 0 || doc.policyCode || relatedVas.length > 0) && (
|
|
||||||
<div className="mb-4 flex flex-wrap items-center gap-2 text-[12px]">
|
|
||||||
{doc.type === "VERFAHREN" && doc.policyCode && (
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{t("operationalizes")}{" "}
|
|
||||||
<Link href={`/policies?doc=${doc.policyCode}`} className="font-semibold text-[var(--info)] hover:underline">
|
|
||||||
{doc.policyCode}
|
|
||||||
</Link>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{controls.map((c) => (
|
|
||||||
<Pill key={c} tone="violet">
|
|
||||||
ISA {c}
|
|
||||||
</Pill>
|
|
||||||
))}
|
|
||||||
{doc.type !== "VERFAHREN" && relatedVas.length > 0 && (
|
|
||||||
<span className="ml-1 flex flex-wrap items-center gap-1.5 text-muted-foreground">
|
|
||||||
{t("procedures")}:
|
|
||||||
{relatedVas.map((va) => (
|
|
||||||
<Link key={va} href={`/policies?doc=${va}`} className="font-semibold text-[var(--info)] hover:underline">
|
|
||||||
{va}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Einklappbare Dokumenten-Info (§7a.5, Standard: eingeklappt) */}
|
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Eingebettete verwaltete Tabelle (§7b) */}
|
|
||||||
{EMBEDDED_REGISTER[doc.code] && (
|
|
||||||
<Link
|
|
||||||
href={`/policies?doc=${EMBEDDED_REGISTER[doc.code].code}`}
|
|
||||||
className="mb-4 flex items-center justify-between gap-2 rounded-xl border border-[var(--band-brd)] bg-[var(--band)] px-4 py-2.5 text-[12.5px] text-[var(--band-text)] hover:opacity-90"
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
<b>Verwaltete Tabelle:</b> {EMBEDDED_REGISTER[doc.code].label}
|
|
||||||
</span>
|
</span>
|
||||||
<span className="font-semibold">{t("openFullTable")} →</span>
|
)}
|
||||||
</Link>
|
{controls.map((c) => (
|
||||||
)}
|
<Pill key={c} tone="violet">
|
||||||
|
ISA {c}
|
||||||
|
</Pill>
|
||||||
|
))}
|
||||||
|
{doc.type !== "VERFAHREN" && relatedVas.length > 0 && (
|
||||||
|
<span className="ml-1 flex flex-wrap items-center gap-1.5 text-muted-foreground">
|
||||||
|
{t("procedures")}:
|
||||||
|
{relatedVas.map((va) => (
|
||||||
|
<Link key={va} href={`/policies/${va}`} className="font-semibold text-[var(--info)] hover:underline">
|
||||||
|
{va}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Gerendertes Dokument */}
|
{/* Einklappbare Dokumenten-Info (§7a.5, Standard: eingeklappt) */}
|
||||||
<article className="policy-prose" dangerouslySetInnerHTML={{ __html: bodyHtml }} />
|
{infoMd && (
|
||||||
</div>
|
<details className="mb-4 rounded-xl border bg-[var(--surface-soft)]">
|
||||||
</Modal>
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Eingebettete verwaltete Tabelle (§7b) */}
|
||||||
|
{EMBEDDED_REGISTER[doc.code] && (
|
||||||
|
<Link
|
||||||
|
href={`/policies/${EMBEDDED_REGISTER[doc.code].code}`}
|
||||||
|
className="mb-4 flex items-center justify-between gap-2 rounded-xl border border-[var(--band-brd)] bg-[var(--band)] px-4 py-2.5 text-[12.5px] text-[var(--band-text)] hover:opacity-90"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<b>Verwaltete Tabelle:</b> {EMBEDDED_REGISTER[doc.code].label}
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold">{t("openFullTable")} →</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Gerendertes Dokument */}
|
||||||
|
<article className="policy-prose" dangerouslySetInnerHTML={{ __html: bodyHtml }} />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getFormatter, getTranslations } from "next-intl/server";
|
import { getFormatter } from "next-intl/server";
|
||||||
import { Pencil, Plus, Trash2 } from "lucide-react";
|
import { Pencil, Plus, Trash2 } from "lucide-react";
|
||||||
import type {
|
import type {
|
||||||
ClassificationClass,
|
ClassificationClass,
|
||||||
@@ -15,8 +15,7 @@ import type {
|
|||||||
} from "@prisma/client";
|
} from "@prisma/client";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Modal } from "@/components/modal";
|
import { Pill } from "@/components/mockup-ui";
|
||||||
import { Pill, Tag } from "@/components/mockup-ui";
|
|
||||||
import { isExpired, isExpiring } from "@/lib/supplier";
|
import { isExpired, isExpiring } from "@/lib/supplier";
|
||||||
import { buildContext, renderPolicyHtml, resolveLink } from "@/lib/policy-render";
|
import { buildContext, renderPolicyHtml, resolveLink } from "@/lib/policy-render";
|
||||||
import {
|
import {
|
||||||
@@ -43,39 +42,27 @@ export interface RegisterData {
|
|||||||
variables?: PolicyVariable[];
|
variables?: PolicyVariable[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Popup für die verwalteten Register (§7b) und das Anwender-Handbuch (§9.7). */
|
/** Inhalt der verwalteten Register (§7b) und des Anwender-Handbuchs (§9.7) — auf eigener Seite. */
|
||||||
export async function PolicyRegisterModal({
|
export async function PolicyRegisterView({
|
||||||
doc,
|
doc,
|
||||||
data,
|
data,
|
||||||
canWrite,
|
canWrite,
|
||||||
backHref,
|
|
||||||
}: {
|
}: {
|
||||||
doc: PolicyDocument;
|
doc: PolicyDocument;
|
||||||
data: RegisterData;
|
data: RegisterData;
|
||||||
canWrite: boolean;
|
canWrite: boolean;
|
||||||
backHref: string;
|
|
||||||
}) {
|
}) {
|
||||||
const t = await getTranslations("policies");
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<div>
|
||||||
title={`${doc.code} · ${doc.title}`}
|
{doc.code === "CRYPTO" && <CryptoRegister entries={data.crypto ?? []} canWrite={canWrite} />}
|
||||||
sub={doc.type === "HANDBUCH" ? "Handbuch" : "Register"}
|
{doc.code === "CLASSIFICATION" && (
|
||||||
headerExtra={<Tag>{doc.type === "HANDBUCH" ? "Handbuch" : "Register"}</Tag>}
|
<ClassificationMatrix classes={data.classes ?? []} aspects={data.aspects ?? []} rules={data.rules ?? []} canWrite={canWrite} />
|
||||||
closeHref={backHref}
|
)}
|
||||||
closeLabel={t("close")}
|
{doc.code === "RISKMATRIX" && (
|
||||||
footer={<Button nativeButton={false} render={<Link href={backHref} />}>{t("close")}</Button>}
|
<RiskMatrix classes={data.riskClasses ?? []} ew={data.ewLevels ?? []} damage={data.damage ?? []} canWrite={canWrite} />
|
||||||
>
|
)}
|
||||||
<div className="max-h-[74vh] overflow-y-auto p-5">
|
{doc.code === "HANDBUCH" && <Handbook topics={data.handbook ?? []} variables={data.variables ?? []} />}
|
||||||
{doc.code === "CRYPTO" && <CryptoRegister entries={data.crypto ?? []} canWrite={canWrite} />}
|
</div>
|
||||||
{doc.code === "CLASSIFICATION" && (
|
|
||||||
<ClassificationMatrix classes={data.classes ?? []} aspects={data.aspects ?? []} rules={data.rules ?? []} canWrite={canWrite} />
|
|
||||||
)}
|
|
||||||
{doc.code === "RISKMATRIX" && (
|
|
||||||
<RiskMatrix classes={data.riskClasses ?? []} ew={data.ewLevels ?? []} damage={data.damage ?? []} canWrite={canWrite} />
|
|
||||||
)}
|
|
||||||
{doc.code === "HANDBUCH" && <Handbook topics={data.handbook ?? []} variables={data.variables ?? []} />}
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,14 +33,14 @@ const SUPPRESS_SECTIONS = [
|
|||||||
/** {{LINK:ZIEL}} → interner Deep-Link nach /policies. */
|
/** {{LINK:ZIEL}} → interner Deep-Link nach /policies. */
|
||||||
export function resolveLink(target: string): { href: string; label: string } {
|
export function resolveLink(target: string): { href: string; label: string } {
|
||||||
const [code, anchor] = target.split("#");
|
const [code, anchor] = target.split("#");
|
||||||
const q = (c: string) => `/policies?doc=${encodeURIComponent(c)}${anchor ? `#${anchor}` : ""}`;
|
const q = (c: string) => `/policies/${encodeURIComponent(c)}${anchor ? `#${anchor}` : ""}`;
|
||||||
switch (code) {
|
switch (code) {
|
||||||
case "NACHWEISREGISTER":
|
case "NACHWEISREGISTER":
|
||||||
return { href: "/policies?doc=NACHWEIS", label: "Nachweisregister" };
|
return { href: "/policies/NACHWEIS", label: "Nachweisregister" };
|
||||||
case "ISA_MAPPING":
|
case "ISA_MAPPING":
|
||||||
return { href: "/policies?view=coverage", label: "ISA-Mapping-Matrix" };
|
return { href: "/policies?view=coverage", label: "ISA-Mapping-Matrix" };
|
||||||
case "BASELINE":
|
case "BASELINE":
|
||||||
return { href: "/policies?doc=BASELINE", label: "Technische Sicherheits-Baseline" };
|
return { href: "/policies/BASELINE", label: "Technische Sicherheits-Baseline" };
|
||||||
default:
|
default:
|
||||||
return { href: q(code), label: anchor ? `${code} §${anchor}` : code };
|
return { href: q(code), label: anchor ? `${code} §${anchor}` : code };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { requireSession } from "@/server/auth";
|
import { requireSession } from "@/server/auth";
|
||||||
import { dbForTenant } from "@/server/db";
|
import { dbForTenant } from "@/server/db";
|
||||||
@@ -35,7 +36,7 @@ export async function addCryptoEntry(formData: FormData) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "crypto_entry" });
|
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "crypto_entry" });
|
||||||
revalidatePath("/policies");
|
revalidatePath("/policies", "layout");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateCryptoEntry(id: string, formData: FormData) {
|
export async function updateCryptoEntry(id: string, formData: FormData) {
|
||||||
@@ -53,14 +54,14 @@ export async function updateCryptoEntry(id: string, formData: FormData) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "crypto_entry", entityId: id });
|
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "crypto_entry", entityId: id });
|
||||||
revalidatePath("/policies");
|
revalidatePath("/policies", "layout");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteCryptoEntry(id: string) {
|
export async function deleteCryptoEntry(id: string) {
|
||||||
const { session, db } = await guard();
|
const { session, db } = await guard();
|
||||||
await db.cryptoEntry.delete({ where: { id } });
|
await db.cryptoEntry.delete({ where: { id } });
|
||||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "crypto_entry", entityId: id });
|
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "crypto_entry", entityId: id });
|
||||||
revalidatePath("/policies");
|
revalidatePath("/policies", "layout");
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Klassifizierungs-Handhabungsmatrix (R02 / VA-08) ── */
|
/* ── Klassifizierungs-Handhabungsmatrix (R02 / VA-08) ── */
|
||||||
@@ -74,7 +75,7 @@ export async function updateHandlingRule(classId: string, aspectId: string, form
|
|||||||
create: { tenantId: session.user.tenantId, classId, aspectId, text },
|
create: { tenantId: session.user.tenantId, classId, aspectId, text },
|
||||||
});
|
});
|
||||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "handling_rule" });
|
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "handling_rule" });
|
||||||
revalidatePath("/policies");
|
revalidatePath("/policies", "layout");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addHandlingAspect(formData: FormData) {
|
export async function addHandlingAspect(formData: FormData) {
|
||||||
@@ -89,7 +90,7 @@ export async function addHandlingAspect(formData: FormData) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "handling_aspect" });
|
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "handling_aspect" });
|
||||||
revalidatePath("/policies");
|
revalidatePath("/policies", "layout");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addClassificationClass(formData: FormData) {
|
export async function addClassificationClass(formData: FormData) {
|
||||||
@@ -104,7 +105,7 @@ export async function addClassificationClass(formData: FormData) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "classification_class" });
|
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "classification_class" });
|
||||||
revalidatePath("/policies");
|
revalidatePath("/policies", "layout");
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Risiko-Bewertungsmatrix (R03 / VA-09) ── */
|
/* ── Risiko-Bewertungsmatrix (R03 / VA-09) ── */
|
||||||
@@ -120,5 +121,28 @@ export async function updateRiskClass(id: string, formData: FormData) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "risk_matrix_class", entityId: id });
|
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "risk_matrix_class", entityId: id });
|
||||||
revalidatePath("/policies");
|
revalidatePath("/policies", "layout");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Bearbeitungsmodus Richtlinien/Verfahren ── */
|
||||||
|
|
||||||
|
/** Vorlagen-Markdown eines Dokuments speichern. */
|
||||||
|
export async function updatePolicyTemplate(code: string, formData: FormData) {
|
||||||
|
const { session, db } = await guard();
|
||||||
|
const doc = await db.policyDocument.findFirst({ where: { code } });
|
||||||
|
if (!doc) throw new Error("Dokument nicht gefunden");
|
||||||
|
const rawMarkdown = String(formData.get("rawMarkdown") ?? "");
|
||||||
|
await db.policyDocument.update({ where: { id: doc.id }, data: { rawMarkdown } });
|
||||||
|
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "policy_document", entityId: doc.id });
|
||||||
|
revalidatePath("/policies", "layout");
|
||||||
|
redirect(`/policies/${encodeURIComponent(code)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine dokument-bezogene Variable ändern (eine Pflegestelle, §7/§8) — propagiert in alle Dokumente. */
|
||||||
|
export async function updatePolicyVariable(key: string, formData: FormData) {
|
||||||
|
const { session, db } = await guard();
|
||||||
|
const value = String(formData.get("value") ?? "");
|
||||||
|
await db.policyVariable.updateMany({ where: { key }, data: { value } });
|
||||||
|
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "policy_variable", after: { key, value } });
|
||||||
|
revalidatePath("/policies", "layout");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user