Referenzmatrix nach Mockup + Risiko-Bewertungsmatrix editierbar
Referenz-/Coverage-Matrix (§9.3) wie im GEFIM-Mockup aufgebaut: - Zwei Richtungen „nach Control" und „nach Dokument" (Umschalter) + Assessment- Export-Button (Platzhalter). - nach Control: Control-Nr + -Titel, je Anforderung MUSS/SOLL-Badge + Text, Richtlinie-Link, Verfahren-Chips. - nach Dokument: gruppiert je Richtlinie (Kopfzeile), Zeilen Control · Anforderung · Umsetzung (Variablen aufgelöst, BL entfernt) · Verfahren. - 44 Control-Titel aus dem Mockup übernommen (lib/control-titles.ts), numerische Control-Sortierung. Risiko-Bewertungsmatrix jetzt editierbar (war nur anzeigend / Edit-Popover wurde vom overflow-Container abgeschnitten): - Risikoklassen, Eintrittswahrscheinlichkeit UND Schadenskategorien inline bearbeitbar (Klick auf Zeile klappt Formular auf — kein abgeschnittenes Popover). - Neue Actions updateEwLevel/updateDamageDimension; Krypto-Register-Clipping (overflow-x-auto) ebenfalls entfernt. Verifiziert: beide Referenz-Richtungen gerendert; Risikoklasse-Edit end-to-end gespeichert (persistiert). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+8
-1
@@ -564,6 +564,13 @@
|
||||
"saveVariables": "Variablen speichern",
|
||||
"modeStandard": "Standard (Variablen)",
|
||||
"modeExpert": "Experten-Modus",
|
||||
"expertHint": "Voller Vorlagen-Editor: Text, Formatierung, Variablen, Deep-Links & Referenzen. Neue Variablen werden beim Speichern angelegt und sind danach im Standard-Modus pflegbar."
|
||||
"expertHint": "Voller Vorlagen-Editor: Text, Formatierung, Variablen, Deep-Links & Referenzen. Neue Variablen werden beim Speichern angelegt und sind danach im Standard-Modus pflegbar.",
|
||||
"byControl": "nach Control",
|
||||
"byDocument": "nach Dokument",
|
||||
"assessmentExport": "Assessment-Export",
|
||||
"requirements": "Anforderung(en)",
|
||||
"requirement": "Anforderung",
|
||||
"implementation": "Umsetzung",
|
||||
"comingSoon": "Bald verfügbar"
|
||||
}
|
||||
}
|
||||
+8
-1
@@ -564,6 +564,13 @@
|
||||
"saveVariables": "Save variables",
|
||||
"modeStandard": "Standard (variables)",
|
||||
"modeExpert": "Expert mode",
|
||||
"expertHint": "Full template editor: text, formatting, variables, deep links & references. New variables are created on save and can then be maintained in standard mode."
|
||||
"expertHint": "Full template editor: text, formatting, variables, deep links & references. New variables are created on save and can then be maintained in standard mode.",
|
||||
"byControl": "by control",
|
||||
"byDocument": "by document",
|
||||
"assessmentExport": "Assessment export",
|
||||
"requirements": "Requirement(s)",
|
||||
"requirement": "Requirement",
|
||||
"implementation": "Implementation",
|
||||
"comingSoon": "Coming soon"
|
||||
}
|
||||
}
|
||||
+138
-53
@@ -5,6 +5,10 @@ import { dbForTenant } from "@/server/db";
|
||||
import { requirePermission } from "@/server/rbac";
|
||||
import { PageHead, Pill, Tag, KpiCard } from "@/components/mockup-ui";
|
||||
import { FilterTabs } from "@/components/filter-tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { controlTitle, compareControl } from "@/lib/control-titles";
|
||||
import { buildContext, renderPolicyMarkdown } from "@/lib/policy-render";
|
||||
import type { PolicyRequirement } from "@prisma/client";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -36,10 +40,57 @@ const STATUS_LABEL: Record<string, string> = {
|
||||
ARCHIVIERT: "Archiviert",
|
||||
};
|
||||
|
||||
function ObligationBadge({ type }: { type: string }) {
|
||||
const muss = type === "MUSS";
|
||||
return (
|
||||
<span className={`inline-block rounded px-1.5 py-0.5 text-[10px] font-bold ${muss ? "bg-[rgba(214,60,94,0.18)] text-[#e88aa0]" : "bg-[rgba(90,169,230,0.18)] text-[var(--info)]"}`}>
|
||||
{type}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Gruppenblock „nach Dokument": Kopfzeile je Richtlinie + Anforderungszeilen. */
|
||||
function PolicyGroup({
|
||||
policy,
|
||||
title,
|
||||
rows,
|
||||
clean,
|
||||
}: {
|
||||
policy: string;
|
||||
title: string;
|
||||
rows: PolicyRequirement[];
|
||||
clean: (s: string, n?: number) => string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<tr className="border-b bg-[var(--surface-soft)]">
|
||||
<td colSpan={4} className="p-2.5">
|
||||
<Link href={`/policies/${policy}`} className="font-heading font-bold hover:underline">{title}</Link>{" "}
|
||||
<span className="text-[11px] text-muted-foreground">{policy}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id} className="border-b align-top last:border-0">
|
||||
<td className="p-3"><b className="font-heading">{r.control}</b></td>
|
||||
<td className="p-3"><ObligationBadge type={r.obligation} /> <span title={clean(r.requirement, 400)}>{clean(r.requirement, 110)}…</span></td>
|
||||
<td className="p-3 text-muted-foreground">{clean(r.implementation, 130)}…</td>
|
||||
<td className="p-3">
|
||||
<span className="flex flex-wrap gap-1">
|
||||
{r.vaCodes.length === 0 ? <span className="text-muted-foreground">–</span> : [...r.vaCodes].sort().map((va) => (
|
||||
<Link key={va} href={`/policies/${va}`}><Pill tone="info">{va}</Pill></Link>
|
||||
))}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function PoliciesPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ type?: string; q?: string; doc?: string; view?: string }>;
|
||||
searchParams: Promise<{ type?: string; q?: string; doc?: string; view?: string; dir?: string }>;
|
||||
}) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "policy:read");
|
||||
@@ -47,10 +98,19 @@ export default async function PoliciesPage({
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const params = await searchParams;
|
||||
|
||||
const [docs, requirements] = await Promise.all([
|
||||
const [docs, requirements, variables] = await Promise.all([
|
||||
db.policyDocument.findMany({ orderBy: { orderIdx: "asc" } }),
|
||||
db.policyRequirement.findMany({ orderBy: [{ control: "asc" }, { reqId: "asc" }] }),
|
||||
db.policyVariable.findMany(),
|
||||
]);
|
||||
const titleOf = (code: string) => docs.find((d) => d.code === code)?.title ?? code;
|
||||
const ctx = buildContext(variables);
|
||||
const clean = (s: string, n = 160) =>
|
||||
renderPolicyMarkdown(s, ctx, { readMode: true, stripBaseline: true })
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, n);
|
||||
|
||||
// Ableitung: je Richtlinie ihre Controls + operationalisierende Verfahren
|
||||
const byPolicy = new Map<string, { controls: Set<string>; vas: Set<string> }>();
|
||||
@@ -79,61 +139,86 @@ export default async function PoliciesPage({
|
||||
/>
|
||||
);
|
||||
|
||||
/* ── Coverage-Matrix (Control → Richtlinie → Verfahren) ── */
|
||||
/* ── Referenz-/Coverage-Matrix (§9.3, nach Mockup) ── */
|
||||
if (isCoverage) {
|
||||
const byControl = new Map<string, { policy: string; must: number; should: number; ids: string[]; vas: Set<string> }>();
|
||||
for (const r of requirements) {
|
||||
if (!byControl.has(r.control)) byControl.set(r.control, { policy: r.policyCode, must: 0, should: 0, ids: [], vas: new Set() });
|
||||
const e = byControl.get(r.control)!;
|
||||
if (r.obligation === "MUSS") e.must++;
|
||||
else e.should++;
|
||||
e.ids.push(r.reqId.split("-").slice(1).join("-"));
|
||||
r.vaCodes.forEach((v) => e.vas.add(v));
|
||||
}
|
||||
const rows = [...byControl.entries()];
|
||||
const dir = params.dir === "doc" ? "doc" : "ctrl";
|
||||
const controlCount = new Set(requirements.map((r) => r.control)).size;
|
||||
const vasOf = (ids: string[]) => [...new Set(ids)].sort();
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
{head}
|
||||
<p className="mt-3 text-[12.5px] text-muted-foreground">{t("coverageHint", { controls: rows.length, reqs: requirements.length })}</p>
|
||||
<div className="shadow-card mt-3 rounded-xl border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("control")}</TableHead>
|
||||
<TableHead>{t("policy")}</TableHead>
|
||||
<TableHead>MUSS</TableHead>
|
||||
<TableHead>SOLL</TableHead>
|
||||
<TableHead>{t("procedures")}</TableHead>
|
||||
<TableHead>{t("reqIds")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map(([control, e]) => (
|
||||
<TableRow key={control}>
|
||||
<TableCell className="font-bold">{control}</TableCell>
|
||||
<TableCell>
|
||||
<Link href={`/policies/${e.policy}`} className="font-semibold hover:underline">{e.policy}</Link>
|
||||
</TableCell>
|
||||
<TableCell>{e.must}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{e.should || "–"}</TableCell>
|
||||
<TableCell>
|
||||
<span className="flex flex-wrap gap-1">
|
||||
{e.vas.size === 0 ? (
|
||||
<span className="text-muted-foreground">–</span>
|
||||
) : (
|
||||
[...e.vas].sort().map((va) => (
|
||||
<Link key={va} href={`/policies/${va}`}>
|
||||
<Pill tone="info">{va}</Pill>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-64 truncate text-[12px] text-muted-foreground">{e.ids.join(", ")}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="mt-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-[12.5px] text-muted-foreground">{t("coverageHint", { controls: controlCount, reqs: requirements.length })}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="inline-flex rounded-lg border bg-card p-1 text-[12px] font-semibold">
|
||||
<Link href="/policies?view=coverage" className={dir === "ctrl" ? "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("byControl")}</Link>
|
||||
<Link href="/policies?view=coverage&dir=doc" className={dir === "doc" ? "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("byDocument")}</Link>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" disabled title={t("comingSoon")}>{t("assessmentExport")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shadow-card mt-3 overflow-x-auto rounded-xl border bg-card">
|
||||
{dir === "ctrl" ? (
|
||||
<table className="w-full text-[12.5px]">
|
||||
<thead>
|
||||
<tr className="border-b bg-[var(--elevated)] text-[10.5px] tracking-[.04em] text-muted-foreground uppercase">
|
||||
<th className="p-3 text-left">{t("control")}</th>
|
||||
<th className="p-3 text-left">{t("requirements")}</th>
|
||||
<th className="p-3 text-left">{t("policy")}</th>
|
||||
<th className="p-3 text-left">{t("procedures")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[...new Set(requirements.map((r) => r.control))].sort(compareControl).map((control) => {
|
||||
const list = requirements.filter((r) => r.control === control);
|
||||
const vas = vasOf(list.flatMap((r) => r.vaCodes));
|
||||
return (
|
||||
<tr key={control} className="border-b align-top last:border-0">
|
||||
<td className="p-3">
|
||||
<b className="font-heading">{control}</b>
|
||||
<div className="text-[11px] text-muted-foreground">{controlTitle(control)}</div>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<ul className="space-y-1.5">
|
||||
{list.map((r) => (
|
||||
<li key={r.id}>
|
||||
<ObligationBadge type={r.obligation} /> <span title={clean(r.requirement, 400)}>{clean(r.requirement, 120)}…</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</td>
|
||||
<td className="p-3"><Link href={`/policies/${list[0].policyCode}`} className="font-semibold hover:underline">{list[0].policyCode}</Link></td>
|
||||
<td className="p-3">
|
||||
<span className="flex flex-wrap gap-1">
|
||||
{vas.length === 0 ? <span className="text-muted-foreground">–</span> : vas.map((va) => (
|
||||
<Link key={va} href={`/policies/${va}`}><Pill tone="info">{va}</Pill></Link>
|
||||
))}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<table className="w-full text-[12.5px]">
|
||||
<thead>
|
||||
<tr className="border-b bg-[var(--elevated)] text-[10.5px] tracking-[.04em] text-muted-foreground uppercase">
|
||||
<th className="p-3 text-left">{t("control")}</th>
|
||||
<th className="p-3 text-left">{t("requirement")}</th>
|
||||
<th className="p-3 text-left">{t("implementation")}</th>
|
||||
<th className="p-3 text-left">{t("procedures")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[...new Set(requirements.map((r) => r.policyCode))].map((policy) => (
|
||||
<PolicyGroup key={policy} policy={policy} title={titleOf(policy)} rows={requirements.filter((r) => r.policyCode === policy)} clean={clean} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
addHandlingAspect,
|
||||
deleteCryptoEntry,
|
||||
updateCryptoEntry,
|
||||
updateDamageDimension,
|
||||
updateEwLevel,
|
||||
updateHandlingRule,
|
||||
updateRiskClass,
|
||||
} from "@/server/actions/policies";
|
||||
@@ -98,7 +100,7 @@ async function CryptoRegister({ entries, canWrite }: { entries: CryptoEntry[]; c
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<div className="rounded-xl border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-[var(--elevated)] text-[10.5px] tracking-[.04em] text-muted-foreground uppercase">
|
||||
@@ -276,73 +278,94 @@ function RiskMatrix({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Risikoklassen + Akzeptanzinstanz */}
|
||||
{/* Risikoklassen + Akzeptanzinstanz — inline editierbar */}
|
||||
<section>
|
||||
<p className="mb-2 font-heading text-sm font-semibold">Risikoklassen & Akzeptanz</p>
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="border-b bg-[var(--elevated)] text-[10.5px] tracking-[.04em] text-muted-foreground uppercase">
|
||||
<th className="p-2.5 text-left">Klasse</th><th className="p-2.5 text-left">Bis Risikowert</th><th className="p-2.5 text-left">Akzeptanzinstanz</th>{canWrite && <th className="p-2.5" />}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{sorted.map((c) => (
|
||||
<tr key={c.id} className="border-b last:border-0">
|
||||
<td className="p-2.5"><Pill tone={(c.tone as "ok" | "warn" | "orange" | "risk")}>{c.name}</Pill></td>
|
||||
<td className="p-2.5 font-medium">≤ {c.maxScore}</td>
|
||||
<td className="p-2.5 text-muted-foreground">{c.acceptance}</td>
|
||||
{canWrite && (
|
||||
<td className="p-2.5 text-right">
|
||||
<details className="relative">
|
||||
<summary className="grid size-7 cursor-pointer list-none place-items-center rounded-md text-muted-foreground hover:bg-muted [&::-webkit-details-marker]:hidden"><Pencil className="size-3.5" /></summary>
|
||||
<form action={updateRiskClass.bind(null, c.id)} className="shadow-card absolute right-0 z-10 mt-2 w-72 space-y-2 rounded-xl border bg-card p-3 text-sm">
|
||||
<Input name="name" required defaultValue={c.name} />
|
||||
<Input name="maxScore" type="number" required defaultValue={c.maxScore} placeholder="Bis Risikowert" />
|
||||
<Input name="acceptance" defaultValue={c.acceptance} placeholder="Akzeptanzinstanz" />
|
||||
<Button type="submit" variant="secondary" size="sm">Speichern</Button>
|
||||
</form>
|
||||
</details>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="mb-2 font-heading text-sm font-semibold">Risikoklassen & Akzeptanz {canWrite && <span className="text-[11px] font-normal text-muted-foreground">· zum Bearbeiten auf eine Zeile klicken</span>}</p>
|
||||
<div className="rounded-xl border">
|
||||
<div className="grid grid-cols-[8rem_7rem_1fr] gap-x-3 border-b bg-[var(--elevated)] px-3 py-2 text-[10.5px] tracking-[.04em] text-muted-foreground uppercase">
|
||||
<span>Klasse</span><span>Bis Risikowert</span><span>Akzeptanzinstanz</span>
|
||||
</div>
|
||||
{sorted.map((c) => (
|
||||
canWrite ? (
|
||||
<details key={c.id} className="border-b last:border-0">
|
||||
<summary className="grid cursor-pointer grid-cols-[8rem_7rem_1fr] items-center gap-x-3 px-3 py-2.5 text-sm select-none hover:bg-muted/40 [&::-webkit-details-marker]:hidden">
|
||||
<span><Pill tone={c.tone as "ok" | "warn" | "orange" | "risk"}>{c.name}</Pill></span>
|
||||
<span className="font-medium">≤ {c.maxScore}</span>
|
||||
<span className="flex items-center justify-between gap-2 text-muted-foreground">{c.acceptance}<Pencil className="size-3.5 shrink-0 opacity-60" /></span>
|
||||
</summary>
|
||||
<form action={updateRiskClass.bind(null, c.id)} className="grid gap-2 border-t bg-[var(--surface-soft)] p-3 sm:grid-cols-[8rem_7rem_1fr_auto]">
|
||||
<Input name="name" required defaultValue={c.name} placeholder="Klasse" className="h-8" />
|
||||
<Input name="maxScore" type="number" required defaultValue={c.maxScore} placeholder="≤ Wert" className="h-8" />
|
||||
<Input name="acceptance" defaultValue={c.acceptance} placeholder="Akzeptanzinstanz" className="h-8" />
|
||||
<Button type="submit" variant="secondary" size="sm">Speichern</Button>
|
||||
</form>
|
||||
</details>
|
||||
) : (
|
||||
<div key={c.id} className="grid grid-cols-[8rem_7rem_1fr] items-center gap-x-3 border-b px-3 py-2.5 text-sm last:border-0">
|
||||
<span><Pill tone={c.tone as "ok" | "warn" | "orange" | "risk"}>{c.name}</Pill></span>
|
||||
<span className="font-medium">≤ {c.maxScore}</span>
|
||||
<span className="text-muted-foreground">{c.acceptance}</span>
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Eintrittswahrscheinlichkeit */}
|
||||
{/* Eintrittswahrscheinlichkeit — inline editierbar */}
|
||||
<section>
|
||||
<p className="mb-2 font-heading text-sm font-semibold">Eintrittswahrscheinlichkeit</p>
|
||||
<ul className="space-y-1 text-[12.5px]">
|
||||
{ew.sort((a, b) => a.level - b.level).map((l) => (
|
||||
<li key={l.id}><b>{l.level} · {l.label}</b> <span className="text-muted-foreground">— {l.definition}</span></li>
|
||||
<div className="rounded-xl border">
|
||||
{[...ew].sort((a, b) => a.level - b.level).map((l) => (
|
||||
canWrite ? (
|
||||
<details key={l.id} className="border-b last:border-0">
|
||||
<summary className="flex cursor-pointer items-center justify-between gap-2 px-3 py-2 text-[12.5px] select-none hover:bg-muted/40 [&::-webkit-details-marker]:hidden">
|
||||
<span><b>{l.level} · {l.label}</b> <span className="text-muted-foreground">— {l.definition}</span></span>
|
||||
<Pencil className="size-3.5 shrink-0 opacity-60" />
|
||||
</summary>
|
||||
<form action={updateEwLevel.bind(null, l.id)} className="grid gap-2 border-t bg-[var(--surface-soft)] p-3 sm:grid-cols-[10rem_1fr_auto]">
|
||||
<Input name="label" required defaultValue={l.label} placeholder="Bezeichnung" className="h-8" />
|
||||
<Input name="definition" defaultValue={l.definition} placeholder="Definition" className="h-8" />
|
||||
<Button type="submit" variant="secondary" size="sm">Speichern</Button>
|
||||
</form>
|
||||
</details>
|
||||
) : (
|
||||
<div key={l.id} className="border-b px-3 py-2 text-[12.5px] last:border-0"><b>{l.level} · {l.label}</b> <span className="text-muted-foreground">— {l.definition}</span></div>
|
||||
)
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Schadenskategorien */}
|
||||
{/* Schadenskategorien — inline editierbar */}
|
||||
<section>
|
||||
<p className="mb-2 font-heading text-sm font-semibold">Schadenskategorien</p>
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<table className="w-full text-[12px]">
|
||||
<thead><tr className="border-b bg-[var(--elevated)] text-[10.5px] tracking-[.04em] text-muted-foreground uppercase">
|
||||
<th className="p-2.5 text-left">Dimension</th><th className="p-2.5 text-left">1 Niedrig</th><th className="p-2.5 text-left">2 Normal</th><th className="p-2.5 text-left">3 Hoch</th><th className="p-2.5 text-left">4 Sehr hoch</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{damage.map((d) => {
|
||||
const lv = d.levels as Record<string, string>;
|
||||
return (
|
||||
<tr key={d.id} className="border-b last:border-0 align-top">
|
||||
<td className="p-2.5 font-medium">{d.name}</td>
|
||||
<td className="p-2.5 text-muted-foreground">{lv["1"]}</td>
|
||||
<td className="p-2.5 text-muted-foreground">{lv["2"]}</td>
|
||||
<td className="p-2.5 text-muted-foreground">{lv["3"]}</td>
|
||||
<td className="p-2.5 text-muted-foreground">{lv["4"]}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="rounded-xl border">
|
||||
<div className="hidden grid-cols-[12rem_1fr_1fr_1fr_1fr] gap-x-3 border-b bg-[var(--elevated)] px-3 py-2 text-[10.5px] tracking-[.04em] text-muted-foreground uppercase sm:grid">
|
||||
<span>Dimension</span><span>1 Niedrig</span><span>2 Normal</span><span>3 Hoch</span><span>4 Sehr hoch</span>
|
||||
</div>
|
||||
{damage.map((d) => {
|
||||
const lv = d.levels as Record<string, string>;
|
||||
return canWrite ? (
|
||||
<details key={d.id} className="border-b last:border-0">
|
||||
<summary className="grid cursor-pointer grid-cols-1 items-center gap-x-3 px-3 py-2.5 text-[12px] select-none hover:bg-muted/40 sm:grid-cols-[12rem_1fr_1fr_1fr_1fr] [&::-webkit-details-marker]:hidden">
|
||||
<span className="flex items-center justify-between gap-2 font-medium">{d.name}<Pencil className="size-3.5 shrink-0 opacity-60 sm:hidden" /></span>
|
||||
<span className="text-muted-foreground">{lv["1"]}</span><span className="text-muted-foreground">{lv["2"]}</span><span className="text-muted-foreground">{lv["3"]}</span><span className="text-muted-foreground">{lv["4"]}</span>
|
||||
</summary>
|
||||
<form action={updateDamageDimension.bind(null, d.id)} className="grid gap-2 border-t bg-[var(--surface-soft)] p-3 sm:grid-cols-2">
|
||||
<Input name="name" required defaultValue={d.name} placeholder="Dimension" className="h-8 sm:col-span-2" />
|
||||
<Input name="l1" defaultValue={lv["1"]} placeholder="1 Niedrig" className="h-8" />
|
||||
<Input name="l2" defaultValue={lv["2"]} placeholder="2 Normal" className="h-8" />
|
||||
<Input name="l3" defaultValue={lv["3"]} placeholder="3 Hoch" className="h-8" />
|
||||
<Input name="l4" defaultValue={lv["4"]} placeholder="4 Sehr hoch" className="h-8" />
|
||||
<Button type="submit" variant="secondary" size="sm" className="sm:col-span-2">Speichern</Button>
|
||||
</form>
|
||||
</details>
|
||||
) : (
|
||||
<div key={d.id} className="grid grid-cols-1 gap-x-3 border-b px-3 py-2.5 text-[12px] last:border-0 sm:grid-cols-[12rem_1fr_1fr_1fr_1fr]">
|
||||
<span className="font-medium">{d.name}</span>
|
||||
<span className="text-muted-foreground">{lv["1"]}</span><span className="text-muted-foreground">{lv["2"]}</span><span className="text-muted-foreground">{lv["3"]}</span><span className="text-muted-foreground">{lv["4"]}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* VDA-ISA-2027 Control-Titel (aus dem GEFIM-Mockup übernommen) für die
|
||||
* Referenz-/Coverage-Matrix. Fallback: „ISA <ref>“ wenn nicht enthalten.
|
||||
*/
|
||||
export const CONTROL_TITLES: Record<string, string> = {
|
||||
"1.2.1": "Steuerung der Informationssicherheit",
|
||||
"1.2.2": "Organisation der Verantwortlichkeiten",
|
||||
"1.2.3": "Informationssicherheit in Projekten",
|
||||
"1.3.1": "Identifikation von Assets",
|
||||
"1.3.2": "Klassifizierung",
|
||||
"1.3.3": "Zugelassene Hardware",
|
||||
"1.3.4": "Zugelassene Software",
|
||||
"1.4.1": "Risikomanagement",
|
||||
"1.5.1": "Compliance-Prüfung im IS-Betrieb",
|
||||
"1.5.2": "Unabhängige Überprüfung",
|
||||
"1.6.1": "Meldung von Ereignissen",
|
||||
"1.6.2": "Behandlung von Sicherheitsereignissen",
|
||||
"1.6.3": "Krisenmanagement",
|
||||
"2.1.1": "Qualifikation für sensible Tätigkeiten",
|
||||
"2.1.2": "Vertragliche Verpflichtung",
|
||||
"2.1.3": "Sensibilisierung und Schulung",
|
||||
"2.1.4": "Mobiles Arbeiten",
|
||||
"3.1.1": "Sicherheitszonen und Zutritt",
|
||||
"3.1.3": "Umgang mit unterstützenden Betriebsmitteln",
|
||||
"3.1.4": "Mobile Geräte und Datenträger",
|
||||
"4.1.1": "Identifikationsmittel",
|
||||
"4.1.2": "Sichere Anmeldung",
|
||||
"4.1.3": "Konten und Anmeldeinformationen",
|
||||
"4.2.1": "Zugriffsrechte",
|
||||
"5.1.1": "Einsatz kryptografischer Verfahren",
|
||||
"5.1.2": "Schutz bei Uebertragung",
|
||||
"5.2.1": "Change-Management",
|
||||
"5.2.2": "Trennung der Umgebungen",
|
||||
"5.2.3": "Schutz vor Malware",
|
||||
"5.2.4": "Protokollierung und Auswertung",
|
||||
"5.2.5": "Schwachstellen- und Patch-Management",
|
||||
"5.2.6": "Technische Überprüfung",
|
||||
"5.2.7": "Netzwerksicherheit",
|
||||
"5.2.8": "Kontinuitätsplanung IT",
|
||||
"5.2.9": "Backup und Wiederherstellung",
|
||||
"5.3.1": "Sicherheit bei Beschaffung/Entwicklung",
|
||||
"5.3.2": "Anforderungen an Netzdienste",
|
||||
"5.3.3": "Rückgabe und sichere Löschung",
|
||||
"5.3.4": "Externe/geteilte IT-Dienste und Cloud",
|
||||
"6.1.1": "Informationssicherheit bei Lieferanten",
|
||||
"6.1.2": "Vertraulichkeit",
|
||||
"6.1.3": "Abgrenzung der Verantwortlichkeiten",
|
||||
"7.1.1": "Regulatorische und vertragliche Compliance",
|
||||
"7.1.2": "Schutz personenbezogener Daten",
|
||||
};
|
||||
|
||||
export function controlTitle(ref: string): string {
|
||||
return CONTROL_TITLES[ref] ?? `ISA ${ref}`;
|
||||
}
|
||||
|
||||
/** Numerischer Vergleich gepunkteter Control-Refs (1.2.10 nach 1.2.2). */
|
||||
export function compareControl(a: string, b: string): number {
|
||||
const pa = a.split('.').map(Number), pb = b.split('.').map(Number);
|
||||
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||||
if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -124,6 +124,29 @@ export async function updateRiskClass(id: string, formData: FormData) {
|
||||
revalidatePath("/policies", "layout");
|
||||
}
|
||||
|
||||
export async function updateEwLevel(id: string, formData: FormData) {
|
||||
const { session, db } = await guard();
|
||||
await db.riskEwLevel.update({
|
||||
where: { id },
|
||||
data: { label: z.string().trim().min(1).parse(formData.get("label")), definition: str(formData.get("definition")) },
|
||||
});
|
||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "risk_ew_level", entityId: id });
|
||||
revalidatePath("/policies", "layout");
|
||||
}
|
||||
|
||||
export async function updateDamageDimension(id: string, formData: FormData) {
|
||||
const { session, db } = await guard();
|
||||
await db.riskDamageDimension.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: z.string().trim().min(1).parse(formData.get("name")),
|
||||
levels: { "1": str(formData.get("l1")), "2": str(formData.get("l2")), "3": str(formData.get("l3")), "4": str(formData.get("l4")) },
|
||||
},
|
||||
});
|
||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "risk_damage_dimension", entityId: id });
|
||||
revalidatePath("/policies", "layout");
|
||||
}
|
||||
|
||||
/* ── Bearbeitungsmodus Richtlinien/Verfahren ── */
|
||||
|
||||
/** Vorlagen-Markdown eines Dokuments speichern (Experten-Modus). */
|
||||
|
||||
Reference in New Issue
Block a user