Iteration 3: Risikoanalyse + Menü-Trennung + Prozesskategorie

- Risiko-Modul (SPEC §4.2): Risk/RiskAsset-Schema mit RLS, laufende
  Nummer je Mandant (R-001), Brutto-/Rest-Bewertung (5×5), Behandlung,
  Status, Owner- und Prozess-Bezug, n:m betroffene Assets
- /risks im Mockup-Layout: 5×5-Heatmap (Farb-Bänder + R-Chips, Legende,
  Achsen) und Risikoregister mit Score-Pillen, Behandlungs-Tags, Status
- Risiko-Popups nach App-Muster: Read-only-Detail (Bewertungs-Band
  Brutto vs. Rest, betroffene Assets, Maßnahmen-Platzhalter für
  Iteration 4), Bearbeiten im Popup (Bewertung, Assets verknüpfen,
  Löschen), Anlegen; Server-Actions mit Zod, RBAC und Audit-Log
- Menü aufgetrennt: eigene Punkte "Asset-Inventar", "Business Impact
  Analyse" und "Risikoanalyse" (Modul-Tabs entfernt)
- Prozesskategorie (Kernprozess/Managementprozess/Unterstützender
  Prozess): Schema-Feld, Formulare, Listen-Spalte, Popup-Tag
- Asset-Detail-Popup optisch ans Prozess-Popup angeglichen (Stammdaten-
  Karte mit violettem Rand, Abhängigkeiten-Tabelle, Risiken-Band)
- "Zugeordnete Risiken" jetzt echt: im Asset-Popup (verknüpfte Risiken)
  und im Prozess-Popup (direkt + über Assets aggregiert); Dashboard-KPI
  "Offene Risiken" mit echten Zahlen; Seed mit 4 Beispiel-Risiken

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Martin
2026-07-02 15:43:25 +02:00
co-authored by Claude Fable 5
parent c5a1f0095b
commit df634e356d
20 changed files with 1633 additions and 106 deletions
+494
View File
@@ -0,0 +1,494 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Pencil, Trash2, X } from "lucide-react";
import type { Prisma } from "@prisma/client";
import {
addRiskAsset,
createRisk,
deleteRisk,
removeRiskAsset,
updateRisk,
} from "@/server/actions/risks";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Modal } from "@/components/modal";
import { CiaBadge, Pill, Tag } from "@/components/mockup-ui";
import { riskLevel, riskRef, RISK_PILL_TONE } from "@/lib/risk";
export type RiskWithDetail = Prisma.RiskGetPayload<{
include: {
owner: { select: { id: true; name: true } };
process: { select: { id: true; name: true } };
riskAssets: {
include: {
asset: {
select: {
id: true;
name: true;
type: true;
confidentiality: true;
integrity: true;
availability: true;
};
};
};
};
};
}>;
const SCALE = [1, 2, 3, 4, 5] as const;
const TREATMENTS = ["AVOID", "MITIGATE", "TRANSFER", "ACCEPT"] as const;
const STATUSES = ["OPEN", "IN_TREATMENT", "ACCEPTED", "CLOSED"] as const;
const STATUS_TONE = {
OPEN: "risk",
IN_TREATMENT: "warn",
ACCEPTED: "info",
CLOSED: "mut",
} as const;
async function scorePill(score: number) {
const tLevel = await getTranslations("riskLevel");
const level = riskLevel(score);
return (
<Pill tone={RISK_PILL_TONE[level]}>
{score} · {tLevel(level)}
</Pill>
);
}
/** Read-only-Risiko-Detail als Popup (SPEC §4.2 Risiko-Detailansicht). */
export async function RiskDetailModal({
risk,
canWrite,
}: {
risk: RiskWithDetail;
canWrite: boolean;
}) {
const t = await getTranslations("risks");
const tType = await getTranslations("assetType");
const tTreat = await getTranslations("riskTreatment");
const tStatus = await getTranslations("riskStatus");
const tc = await getTranslations("common");
return (
<Modal
title={t("detailHeading", { ref: riskRef(risk.refNo) })}
sub={t("detailSub")}
headerExtra={
<span className="flex items-center gap-2">
<Pill tone={STATUS_TONE[risk.status]}>{tStatus(risk.status)}</Pill>
{await scorePill(risk.score)}
</span>
}
closeHref="/risks"
closeLabel={t("close")}
footer={
<>
{canWrite && (
<Button
variant="outline"
nativeButton={false}
render={<Link href={`/risks?edit=${risk.id}`} />}
>
<Pencil className="size-4" /> {tc("edit")}
</Button>
)}
<Button nativeButton={false} render={<Link href="/risks" />}>
{t("close")}
</Button>
</>
}
>
<div className="grid gap-5 p-5 md:grid-cols-2">
<div>
<div className="rounded-xl border border-l-[3px] border-l-[#5d52a3] bg-[#faf9fd] p-4">
<b>{risk.title}</b>
{risk.description && (
<p className="mt-1.5 text-[12.5px] leading-relaxed">{risk.description}</p>
)}
<dl className="mt-3 grid grid-cols-[7.5rem_1fr] gap-1.5 text-[12.5px]">
<dt className="text-muted-foreground">{t("threat")}</dt>
<dd>{risk.threat ?? tc("none")}</dd>
<dt className="text-muted-foreground">{t("vulnerability")}</dt>
<dd>{risk.vulnerability ?? tc("none")}</dd>
<dt className="text-muted-foreground">{t("owner")}</dt>
<dd>{risk.owner?.name ?? tc("none")}</dd>
<dt className="text-muted-foreground">{t("treatment")}</dt>
<dd>
<Tag>{tTreat(risk.treatment)}</Tag>
</dd>
<dt className="text-muted-foreground">{t("process")}</dt>
<dd>
{risk.process ? (
<Link href={`/processes?detail=${risk.process.id}`} className="hover:underline">
{risk.process.name}
</Link>
) : (
tc("none")
)}
</dd>
</dl>
</div>
</div>
<div>
<div className="mb-2.5 flex items-center gap-2.5">
<Pill tone="info">{t("affectedAssets")}</Pill>
<span className="text-[12.5px] text-muted-foreground">{t("affectedNote")}</span>
</div>
{risk.riskAssets.length === 0 && (
<p className="text-sm text-muted-foreground">{t("noAssets")}</p>
)}
<table className="w-full text-sm">
<tbody>
{risk.riskAssets.map((ra) => (
<tr key={ra.id} className="border-b last:border-0">
<td className="py-2.5 pr-2 font-bold">
<Link href={`/assets?detail=${ra.asset.id}`} className="hover:underline">
{ra.asset.name}
</Link>
</td>
<td className="py-2.5 pr-2">
<Tag>{tType(ra.asset.type)}</Tag>
</td>
<td className="py-2.5 text-right">
<CiaBadge
c={ra.asset.confidentiality}
i={ra.asset.integrity}
a={ra.asset.availability}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Brutto- vs. Rest-Risiko */}
<div className="mx-5 rounded-xl border border-[#e6e2f3] bg-[#f2f0f9] p-4 text-[12.5px] text-[#5a4e86]">
<div className="grid gap-2 sm:grid-cols-2">
<div>
<b>{t("gross")}</b>
<div className="mt-1.5 flex items-center gap-2">
{risk.likelihood} × {risk.impact} = {await scorePill(risk.score)}
</div>
</div>
<div>
<b>{t("residual")}</b>
<div className="mt-1.5 flex items-center gap-2">
{risk.residualScore != null ? (
<>
{risk.residualLikelihood} × {risk.residualImpact} ={" "}
{await scorePill(risk.residualScore)}
</>
) : (
t("noResidual")
)}
</div>
<p className="mt-1 text-muted-foreground">{t("residualHint")}</p>
</div>
</div>
</div>
<div className="mx-5 mb-5 mt-4">
<p className="text-sm font-semibold">{t("measures")}</p>
<p className="mt-1 text-sm text-muted-foreground">{t("measuresPlaceholder")}</p>
</div>
</Modal>
);
}
/** Bearbeiten als Popup: Bewertung, Behandlung, betroffene Assets, Löschen. */
export async function RiskEditModal({
risk,
users,
processes,
availableAssets,
}: {
risk: RiskWithDetail;
users: { id: string; name: string }[];
processes: { id: string; name: string }[];
availableAssets: { id: string; name: string }[];
}) {
const t = await getTranslations("risks");
const tc = await getTranslations("common");
const selectClass = "h-9 rounded-md border border-input bg-transparent px-3 text-sm";
return (
<Modal
title={t("editTitle")}
sub={`${riskRef(risk.refNo)} · ${risk.title}`}
closeHref={`/risks?detail=${risk.id}`}
closeLabel={t("close")}
>
<div className="grid gap-6 p-5 md:grid-cols-2">
<form
key={risk.updatedAt.toISOString()}
action={updateRisk.bind(null, risk.id)}
className="space-y-4"
>
<RiskFields risk={risk} users={users} processes={processes} selectClass={selectClass} />
<Button type="submit">{tc("save")}</Button>
</form>
<div className="space-y-4 text-sm">
<div>
<p className="font-medium">{t("affectedAssets")}</p>
<p className="mb-1 text-xs text-muted-foreground">{t("affectedNote")}</p>
{risk.riskAssets.length === 0 && <p>{tc("none")}</p>}
<ul className="space-y-1.5">
{risk.riskAssets.map((ra) => (
<li key={ra.id} className="flex items-center gap-2">
{ra.asset.name}
<form action={removeRiskAsset.bind(null, risk.id, ra.id)}>
<button
type="submit"
title={tc("remove")}
className="text-muted-foreground hover:text-destructive"
>
<X className="size-3.5" />
</button>
</form>
</li>
))}
</ul>
</div>
{availableAssets.length > 0 && (
<form action={addRiskAsset.bind(null, risk.id)} className="flex gap-2 border-t pt-3">
<select name="assetId" required className={`${selectClass} flex-1`}>
{availableAssets.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
</option>
))}
</select>
<Button type="submit" variant="secondary" size="sm">
{t("addAsset")}
</Button>
</form>
)}
<div className="border-t pt-4">
<form action={deleteRisk.bind(null, risk.id)}>
<Button type="submit" variant="destructive" size="sm">
<Trash2 className="size-4" /> {tc("delete")}
</Button>
</form>
</div>
</div>
</div>
</Modal>
);
}
/** Formularfelder (Anlegen + Bearbeiten teilen sich das Markup). */
async function RiskFields({
risk,
users,
processes,
selectClass,
}: {
risk?: RiskWithDetail;
users: { id: string; name: string }[];
processes: { id: string; name: string }[];
selectClass: string;
}) {
const t = await getTranslations("risks");
const tTreat = await getTranslations("riskTreatment");
const tStatus = await getTranslations("riskStatus");
const tc = await getTranslations("common");
return (
<>
<div>
<Label htmlFor="title">{t("titleField")}</Label>
<Input id="title" name="title" required defaultValue={risk?.title} className="mt-1" />
</div>
<div>
<Label htmlFor="description">{t("description")}</Label>
<Textarea
id="description"
name="description"
rows={2}
defaultValue={risk?.description ?? ""}
className="mt-1"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="threat">{t("threat")}</Label>
<Input id="threat" name="threat" defaultValue={risk?.threat ?? ""} className="mt-1" />
</div>
<div>
<Label htmlFor="vulnerability">{t("vulnerability")}</Label>
<Input
id="vulnerability"
name="vulnerability"
defaultValue={risk?.vulnerability ?? ""}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="likelihood">{t("likelihood")}</Label>
<select
id="likelihood"
name="likelihood"
defaultValue={risk?.likelihood ?? 3}
className={`${selectClass} mt-1 w-full`}
>
{SCALE.map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="impact">{t("impact")}</Label>
<select
id="impact"
name="impact"
defaultValue={risk?.impact ?? 3}
className={`${selectClass} mt-1 w-full`}
>
{SCALE.map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="residualLikelihood">
{t("residual")} {t("likelihood")}
</Label>
<select
id="residualLikelihood"
name="residualLikelihood"
defaultValue={risk?.residualLikelihood ?? ""}
className={`${selectClass} mt-1 w-full`}
>
<option value="">{tc("none")}</option>
{SCALE.map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="residualImpact">
{t("residual")} {t("impact")}
</Label>
<select
id="residualImpact"
name="residualImpact"
defaultValue={risk?.residualImpact ?? ""}
className={`${selectClass} mt-1 w-full`}
>
<option value="">{tc("none")}</option>
{SCALE.map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="treatment">{t("treatment")}</Label>
<select
id="treatment"
name="treatment"
defaultValue={risk?.treatment ?? "MITIGATE"}
className={`${selectClass} mt-1 w-full`}
>
{TREATMENTS.map((v) => (
<option key={v} value={v}>
{tTreat(v)}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="status">{t("status")}</Label>
<select
id="status"
name="status"
defaultValue={risk?.status ?? "OPEN"}
className={`${selectClass} mt-1 w-full`}
>
{STATUSES.map((v) => (
<option key={v} value={v}>
{tStatus(v)}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="ownerId">{t("owner")}</Label>
<select
id="ownerId"
name="ownerId"
defaultValue={risk?.ownerId ?? ""}
className={`${selectClass} mt-1 w-full`}
>
<option value="">{tc("none")}</option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="processId">{t("process")}</Label>
<select
id="processId"
name="processId"
defaultValue={risk?.processId ?? ""}
className={`${selectClass} mt-1 w-full`}
>
<option value="">{tc("none")}</option>
{processes.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
</div>
</>
);
}
/** Anlegen als Popup. */
export async function RiskCreateModal({
users,
processes,
}: {
users: { id: string; name: string }[];
processes: { id: string; name: string }[];
}) {
const t = await getTranslations("risks");
const tc = await getTranslations("common");
const selectClass = "h-9 rounded-md border border-input bg-transparent px-3 text-sm";
return (
<Modal title={t("createTitle")} closeHref="/risks" closeLabel={t("close")}>
<form action={createRisk} className="space-y-4 p-5">
<RiskFields users={users} processes={processes} selectClass={selectClass} />
<div className="flex gap-2 pt-1">
<Button type="submit">{tc("save")}</Button>
<Button variant="outline" nativeButton={false} render={<Link href="/risks" />}>
{tc("cancel")}
</Button>
</div>
</form>
</Modal>
);
}