Delta-Update des VDA-ISA-2027-Vorlagenpakets eingepflegt: - Aktualisiertes Seed-Paket (ersetzt bisherigen Stand): 316 Anforderungen (122 MUSS · 132 SOLL · 43 HOCH · 19 SEHR HOCH) über 45 Controls; VA-01/VA-05 jetzt enthalten (13 Verfahren vollständig). Beschädigte RACI-Tokens (VA-05/08/09/ 10/12/13) repariert. Importer: Umsetzungstext aus den .md-IMPL-Ankern extrahiert (mapping.json führt ihn nicht mehr), neue Obligation-Typen HOCH/SEHR HOCH. - Neue Schutzbedarf-Flags (variables.schema): FLAG_HIGH_PROTECTION, FLAG_VERY_HIGH_PROTECTION, FLAG_ELEVATED_PROTECTION (abgeleitet). Render-Helper applyProtection: HIGH stets an, VERY_HIGH aus Global/Override, ELEVATED = HIGH||VH (nie manuell) — angewandt in Lese-, Bearbeiten-, Handbuch- und Coverage-Rendering. - TISAX-Level-Schalter (AL2/AL3) zentral auf der Bibliothek (setGlobalTisaxLevel); AL2 = MUSS/SOLL/HOCH, AL3 = zusätzlich SEHR HOCH. Override je Richtlinie im Bearbeitungsmodus (setProtectionOverride, Feld protection_override); effektiver Wert = Dokument-Override sonst global. - KPIs zeigen 316 Anforderungen mit Aufschlüsselung; Coverage/Badges für HOCH/SEHR HOCH; Control-Titel-Fallback. Verifiziert: Import 316/45; Rendering rückstandsfrei über AL2/AL3 × Flag-Kombis; Override R04→AL3 zeigt SEHR-HOCH-Inhalt, R02 (global AL2) nicht; global bleibt AL2. Architektur-Hinweis: applyProtection kapselt das Level→Flags-Mapping, sodass die globale Ebene später ohne Umbau zur TISAX-AL2/AL3-Auswahl wird (bereits so gebaut). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
412 lines
22 KiB
TypeScript
412 lines
22 KiB
TypeScript
import Link from "next/link";
|
||
import { getFormatter } from "next-intl/server";
|
||
import { Pencil, Plus, Trash2 } from "lucide-react";
|
||
import type {
|
||
ClassificationClass,
|
||
CryptoEntry,
|
||
HandbookTopic,
|
||
HandlingAspect,
|
||
HandlingRule,
|
||
PolicyDocument,
|
||
PolicyVariable,
|
||
RiskDamageDimension,
|
||
RiskEwLevel,
|
||
RiskMatrixClass,
|
||
} from "@prisma/client";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Pill } from "@/components/mockup-ui";
|
||
import { isExpired, isExpiring } from "@/lib/supplier";
|
||
import { applyProtection, buildContext, renderPolicyHtml, resolveLink } from "@/lib/policy-render";
|
||
import {
|
||
addClassificationClass,
|
||
addCryptoEntry,
|
||
addHandlingAspect,
|
||
deleteCryptoEntry,
|
||
updateCryptoEntry,
|
||
updateDamageDimension,
|
||
updateEwLevel,
|
||
updateHandlingRule,
|
||
updateRiskClass,
|
||
} from "@/server/actions/policies";
|
||
|
||
const TONE_HEX: Record<string, string> = { ok: "#2ea86b", warn: "#e3b427", orange: "#e2802e", risk: "#d63c5e" };
|
||
|
||
export interface RegisterData {
|
||
crypto?: CryptoEntry[];
|
||
classes?: ClassificationClass[];
|
||
aspects?: HandlingAspect[];
|
||
rules?: HandlingRule[];
|
||
riskClasses?: RiskMatrixClass[];
|
||
ewLevels?: RiskEwLevel[];
|
||
damage?: RiskDamageDimension[];
|
||
handbook?: HandbookTopic[];
|
||
variables?: PolicyVariable[];
|
||
}
|
||
|
||
/** Inhalt der verwalteten Register (§7b) und des Anwender-Handbuchs (§9.7) — auf eigener Seite. */
|
||
export async function PolicyRegisterView({
|
||
doc,
|
||
data,
|
||
canWrite,
|
||
}: {
|
||
doc: PolicyDocument;
|
||
data: RegisterData;
|
||
canWrite: boolean;
|
||
}) {
|
||
return (
|
||
<div>
|
||
{doc.code === "CRYPTO" && <CryptoRegister entries={data.crypto ?? []} canWrite={canWrite} />}
|
||
{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>
|
||
);
|
||
}
|
||
|
||
/* ─────────────── Krypto-Register ─────────────── */
|
||
|
||
async function CryptoRegister({ entries, canWrite }: { entries: CryptoEntry[]; canWrite: boolean }) {
|
||
const fmt = await getFormatter();
|
||
const date = (d: Date | null) => (d ? fmt.dateTime(d, { dateStyle: "medium" }) : "—");
|
||
const expiring = entries.filter((e) => isExpiring(e.ablaufdatum) || isExpired(e.ablaufdatum)).length;
|
||
|
||
return (
|
||
<div>
|
||
<div className="mb-3 flex items-center justify-between gap-2">
|
||
<p className="text-[12.5px] text-muted-foreground">
|
||
Eingesetzte Verschlüsselung mit Ablaufüberwachung.{" "}
|
||
{expiring > 0 && <span className="font-semibold text-[var(--warn)]">{expiring} laufen bald ab / abgelaufen.</span>}
|
||
</p>
|
||
{canWrite && (
|
||
<details className="relative">
|
||
<summary className="bg-grad-soft inline-flex h-8 cursor-pointer list-none items-center gap-1.5 rounded-lg px-2.5 font-heading text-sm font-semibold text-white select-none hover:opacity-90 [&::-webkit-details-marker]:hidden">
|
||
<Plus className="size-4" /> Eintrag
|
||
</summary>
|
||
<form action={addCryptoEntry} className="shadow-card absolute right-0 z-10 mt-2 w-80 space-y-2 rounded-xl border bg-card p-3 text-sm">
|
||
<Input name="dienst" required placeholder="Dienst" />
|
||
<Input name="schluessel" required placeholder="Schlüssel" />
|
||
<Input name="algorithmus" placeholder="Algorithmus / Länge" />
|
||
<Input name="ablaufdatum" type="date" />
|
||
<Input name="verantwortlich" placeholder="Verantwortlich" />
|
||
<Input name="speicherort" placeholder="Speicherort" />
|
||
<Input name="baselineRef" placeholder="Baseline (BL-CRY-…)" />
|
||
<Button type="submit" variant="secondary" size="sm">Hinzufügen</Button>
|
||
</form>
|
||
</details>
|
||
)}
|
||
</div>
|
||
<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">
|
||
<th className="p-2.5 text-left">Dienst</th>
|
||
<th className="p-2.5 text-left">Schlüssel</th>
|
||
<th className="p-2.5 text-left">Algorithmus</th>
|
||
<th className="p-2.5 text-left">Ablauf</th>
|
||
<th className="p-2.5 text-left">Verantwortlich</th>
|
||
<th className="p-2.5 text-left">Baseline</th>
|
||
{canWrite && <th className="p-2.5" />}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{entries.length === 0 && (
|
||
<tr><td colSpan={7} className="p-6 text-center text-muted-foreground">Noch keine Einträge.</td></tr>
|
||
)}
|
||
{entries.map((e) => {
|
||
const exp = isExpired(e.ablaufdatum);
|
||
const soon = !exp && isExpiring(e.ablaufdatum);
|
||
return (
|
||
<tr key={e.id} className="border-b last:border-0">
|
||
<td className="p-2.5 font-medium">{e.dienst}</td>
|
||
<td className="p-2.5 text-muted-foreground">{e.schluessel}</td>
|
||
<td className="p-2.5 text-muted-foreground">{e.algorithmus ?? "—"}</td>
|
||
<td className="p-2.5">
|
||
<span className={exp ? "font-semibold text-[var(--risk)]" : soon ? "font-semibold text-[var(--warn)]" : "text-muted-foreground"}>
|
||
{date(e.ablaufdatum)}
|
||
</span>
|
||
{exp && <Pill tone="risk">abgelaufen</Pill>}
|
||
{soon && <Pill tone="warn">läuft ab</Pill>}
|
||
</td>
|
||
<td className="p-2.5 text-muted-foreground">{e.verantwortlich}</td>
|
||
<td className="p-2.5">{e.baselineRef ? <Pill tone="violet">{e.baselineRef}</Pill> : <span className="text-muted-foreground">—</span>}</td>
|
||
{canWrite && (
|
||
<td className="p-2.5">
|
||
<div className="flex items-center justify-end gap-1">
|
||
<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={updateCryptoEntry.bind(null, e.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="dienst" required defaultValue={e.dienst} />
|
||
<Input name="schluessel" required defaultValue={e.schluessel} />
|
||
<Input name="algorithmus" defaultValue={e.algorithmus ?? ""} placeholder="Algorithmus" />
|
||
<Input name="ablaufdatum" type="date" defaultValue={e.ablaufdatum ? e.ablaufdatum.toISOString().slice(0, 10) : ""} />
|
||
<Input name="verantwortlich" defaultValue={e.verantwortlich} />
|
||
<Input name="speicherort" defaultValue={e.speicherort ?? ""} placeholder="Speicherort" />
|
||
<Input name="baselineRef" defaultValue={e.baselineRef ?? ""} placeholder="BL-CRY-…" />
|
||
<Button type="submit" variant="secondary" size="sm">Speichern</Button>
|
||
</form>
|
||
</details>
|
||
<form action={deleteCryptoEntry.bind(null, e.id)}>
|
||
<button type="submit" title="Löschen" className="grid size-7 place-items-center rounded-md text-muted-foreground hover:text-destructive"><Trash2 className="size-3.5" /></button>
|
||
</form>
|
||
</div>
|
||
</td>
|
||
)}
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ─────────────── Klassifizierungs-Handhabungsmatrix ─────────────── */
|
||
|
||
function ClassificationMatrix({
|
||
classes, aspects, rules, canWrite,
|
||
}: { classes: ClassificationClass[]; aspects: HandlingAspect[]; rules: HandlingRule[]; canWrite: boolean }) {
|
||
const ruleOf = (classId: string, aspectId: string) => rules.find((r) => r.classId === classId && r.aspectId === aspectId)?.text ?? "";
|
||
return (
|
||
<div>
|
||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||
<p className="text-[12.5px] text-muted-foreground">Handhabungsregeln je Schutzklasse. Assets erben die Regeln aus ihrer Klasse.</p>
|
||
{canWrite && (
|
||
<div className="flex gap-2">
|
||
<details className="relative">
|
||
<summary className="inline-flex h-8 cursor-pointer list-none items-center gap-1.5 rounded-lg border px-2.5 text-[12.5px] font-semibold text-muted-foreground hover:text-foreground [&::-webkit-details-marker]:hidden"><Plus className="size-3.5" /> Aspekt</summary>
|
||
<form action={addHandlingAspect} 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 placeholder="Aspekt (Zeile)" />
|
||
<Input name="category" placeholder="Kategorie" />
|
||
<Button type="submit" variant="secondary" size="sm">Hinzufügen</Button>
|
||
</form>
|
||
</details>
|
||
<details className="relative">
|
||
<summary className="inline-flex h-8 cursor-pointer list-none items-center gap-1.5 rounded-lg border px-2.5 text-[12.5px] font-semibold text-muted-foreground hover:text-foreground [&::-webkit-details-marker]:hidden"><Plus className="size-3.5" /> Schutzklasse</summary>
|
||
<form action={addClassificationClass} 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 placeholder="Schutzklasse (Spalte)" />
|
||
<Input name="description" placeholder="Beschreibung" />
|
||
<Button type="submit" variant="secondary" size="sm">Hinzufügen</Button>
|
||
</form>
|
||
</details>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="overflow-x-auto rounded-xl border">
|
||
<table className="w-full text-[12.5px]">
|
||
<thead>
|
||
<tr className="border-b bg-[var(--elevated)]">
|
||
<th className="p-2.5 text-left text-[10.5px] tracking-[.04em] text-muted-foreground uppercase">Aspekt</th>
|
||
{classes.map((c) => (
|
||
<th key={c.id} className="min-w-40 p-2.5 text-left font-heading font-bold" title={c.description ?? ""}>{c.name}</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{aspects.map((a) => (
|
||
<tr key={a.id} className="border-b last:border-0 align-top">
|
||
<td className="p-2.5 font-medium">{a.name}</td>
|
||
{classes.map((c) => (
|
||
<td key={c.id} className="p-1.5">
|
||
{canWrite ? (
|
||
<details className="group">
|
||
<summary className="cursor-pointer list-none rounded-md px-2 py-1.5 hover:bg-muted/50 [&::-webkit-details-marker]:hidden">{ruleOf(c.id, a.id) || <span className="text-muted-foreground">—</span>}</summary>
|
||
<form action={updateHandlingRule.bind(null, c.id, a.id)} className="mt-1 flex gap-1">
|
||
<Input name="text" defaultValue={ruleOf(c.id, a.id)} className="h-8" />
|
||
<Button type="submit" variant="secondary" size="sm">OK</Button>
|
||
</form>
|
||
</details>
|
||
) : (
|
||
<span className="px-2">{ruleOf(c.id, a.id) || "—"}</span>
|
||
)}
|
||
</td>
|
||
))}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ─────────────── Risiko-Bewertungsmatrix ─────────────── */
|
||
|
||
function RiskMatrix({
|
||
classes, ew, damage, canWrite,
|
||
}: { classes: RiskMatrixClass[]; ew: RiskEwLevel[]; damage: RiskDamageDimension[]; canWrite: boolean }) {
|
||
const sorted = [...classes].sort((a, b) => a.maxScore - b.maxScore);
|
||
const classFor = (score: number) => sorted.find((c) => score <= c.maxScore) ?? sorted[sorted.length - 1];
|
||
const rows = [4, 3, 2, 1]; // Schadensausmaß (oben = 4)
|
||
const cols = [1, 2, 3, 4]; // Eintrittswahrscheinlichkeit
|
||
|
||
return (
|
||
<div className="space-y-5">
|
||
{/* 4×4 Heatmap */}
|
||
<section>
|
||
<p className="mb-2 font-heading text-sm font-semibold">Bewertungsmatrix (Schadensausmaß × Eintrittswahrscheinlichkeit)</p>
|
||
<div className="inline-block overflow-hidden rounded-xl border">
|
||
<table className="text-center text-[12.5px]">
|
||
<tbody>
|
||
{rows.map((s) => (
|
||
<tr key={s}>
|
||
<th className="bg-[var(--elevated)] px-3 py-2 text-muted-foreground">S {s}</th>
|
||
{cols.map((e) => {
|
||
const score = s * e;
|
||
const cls = classFor(score);
|
||
return (
|
||
<td key={e} className="px-4 py-2 font-bold text-white" style={{ background: TONE_HEX[cls?.tone] ?? "#555" }} title={`${cls?.name} (${score})`}>
|
||
{score}
|
||
</td>
|
||
);
|
||
})}
|
||
</tr>
|
||
))}
|
||
<tr>
|
||
<th className="bg-[var(--elevated)] px-3 py-2" />
|
||
{cols.map((e) => (
|
||
<th key={e} className="bg-[var(--elevated)] px-4 py-2 text-muted-foreground">EW {e}</th>
|
||
))}
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
|
||
{/* Risikoklassen + Akzeptanzinstanz — inline editierbar */}
|
||
<section>
|
||
<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 — inline editierbar */}
|
||
<section>
|
||
<p className="mb-2 font-heading text-sm font-semibold">Eintrittswahrscheinlichkeit</p>
|
||
<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>
|
||
)
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
{/* Schadenskategorien — inline editierbar */}
|
||
<section>
|
||
<p className="mb-2 font-heading text-sm font-semibold">Schadenskategorien</p>
|
||
<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>
|
||
);
|
||
}
|
||
|
||
/* ─────────────── Anwender-Handbuch ─────────────── */
|
||
|
||
function Handbook({ topics, variables }: { topics: HandbookTopic[]; variables: PolicyVariable[] }) {
|
||
const ctx = applyProtection(buildContext(variables));
|
||
const cats = [...new Set(topics.map((t) => t.category))];
|
||
return (
|
||
<div className="space-y-5">
|
||
<p className="text-[12.5px] text-muted-foreground">
|
||
Kompakte Regeln für Mitarbeitende. Werte (z. B. Passwortlänge) stammen aus der zentralen Baseline und bleiben automatisch synchron.
|
||
</p>
|
||
{cats.map((cat) => (
|
||
<section key={cat}>
|
||
<p className="mb-2 font-heading text-sm font-semibold text-[var(--band-text)]">{cat}</p>
|
||
<div className="space-y-3">
|
||
{topics.filter((t) => t.category === cat).map((topic) => (
|
||
<div key={topic.id} className="rounded-xl border border-l-[3px] border-l-[var(--primary)] bg-[var(--surface-soft)] p-4">
|
||
<p className="font-heading text-[14px] font-bold">{topic.title}</p>
|
||
<div className="policy-prose mt-1" dangerouslySetInnerHTML={{ __html: renderPolicyHtml(topic.bodyMd, ctx, { readMode: false }) }} />
|
||
{topic.sourceRefs.length > 0 && (
|
||
<div className="mt-2 flex flex-wrap items-center gap-2 text-[12px] text-muted-foreground">
|
||
Details:
|
||
{topic.sourceRefs.map((ref) => {
|
||
const { href, label } = resolveLink(ref);
|
||
return (
|
||
<Link key={ref} href={href} className="font-semibold text-[var(--info)] hover:underline">{label} →</Link>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|