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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user