Iteration 4 (Maßnahmen) + Risikoanalyse nachgeschärft
Risikoanalyse:
- Risiko direkt aus dem Asset-Popup erstellbar ("+ Risiko erstellen"
→ Anlege-Popup mit vorverknüpftem Asset)
- Brutto-/Rest-Risiko detailliert: Eintrittswahrscheinlichkeit und
Schaden als beschriftete Werte (X × Y = Score-Pille)
- Rest-Risiko wird NICHT mehr manuell erfasst, sondern automatisch aus
den verknüpften Maßnahmen berechnet (Minderung je Dimension, Summe,
Untergrenze 1; src/server/risk-calc.ts); Neuberechnung bei jeder
Änderung an Bewertung oder Maßnahmen-Verknüpfungen
- Risikoregister unterhalb der Heatmap in voller Breite
Maßnahmen-Modul (SPEC §4.4):
- Measure/RiskMeasure-Schema mit RLS, laufende Nummer (M-001),
Status/Priorität/Owner/Fälligkeit
- Kanban-Board mit Drag-and-Drop (dnd-kit): Karten zwischen Offen /
In Umsetzung / Erledigt verschieben aktualisiert den Status per
Server-Action inkl. Audit-Log; überfällige Karten rot markiert
- Maßnahmen-Popups (Detail read-only / Bearbeiten / Anlegen) nach
App-Muster; Detail zeigt verknüpfte Risiken mit Minderung
- Im Risiko-Bearbeiten: bestehende Maßnahme verknüpfen ODER neue
Maßnahme direkt anlegen & verknüpfen (jeweils mit Minderung
Wahrscheinlichkeit/Schaden); "Notwendige Maßnahmen" im Risiko-Detail
jetzt echt
- Seed: 4 Beispiel-Maßnahmen, Rest-Risiken daraus berechnet
Verifiziert im Browser: DnD-Statuswechsel (beide Richtungen, Audit-
Einträge), Rest-Risiko-Berechnung (R-001: 4×5=20 → 2×4=8), Risiko-
Anlage aus dem Asset inkl. automatischer Verknüpfung.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+214
-76
@@ -9,6 +9,11 @@ import {
|
||||
removeRiskAsset,
|
||||
updateRisk,
|
||||
} from "@/server/actions/risks";
|
||||
import {
|
||||
createMeasureForRisk,
|
||||
linkMeasureToRisk,
|
||||
unlinkMeasureFromRisk,
|
||||
} from "@/server/actions/measures";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -16,6 +21,7 @@ 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";
|
||||
import { measureRef, MEASURE_STATUS_TONE } from "@/lib/measure";
|
||||
|
||||
export type RiskWithDetail = Prisma.RiskGetPayload<{
|
||||
include: {
|
||||
@@ -35,10 +41,16 @@ export type RiskWithDetail = Prisma.RiskGetPayload<{
|
||||
};
|
||||
};
|
||||
};
|
||||
riskMeasures: {
|
||||
include: {
|
||||
measure: { select: { id: true; refNo: true; title: true; status: true } };
|
||||
};
|
||||
};
|
||||
};
|
||||
}>;
|
||||
|
||||
const SCALE = [1, 2, 3, 4, 5] as const;
|
||||
const REDUCTIONS = [0, 1, 2, 3, 4] as const;
|
||||
const TREATMENTS = ["AVOID", "MITIGATE", "TRANSFER", "ACCEPT"] as const;
|
||||
const STATUSES = ["OPEN", "IN_TREATMENT", "ACCEPTED", "CLOSED"] as const;
|
||||
|
||||
@@ -49,7 +61,7 @@ const STATUS_TONE = {
|
||||
CLOSED: "mut",
|
||||
} as const;
|
||||
|
||||
async function scorePill(score: number) {
|
||||
async function ScorePill({ score }: { score: number }) {
|
||||
const tLevel = await getTranslations("riskLevel");
|
||||
const level = riskLevel(score);
|
||||
return (
|
||||
@@ -59,6 +71,52 @@ async function scorePill(score: number) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Bewertungs-Block: Wahrscheinlichkeit & Schaden mit Beschriftung + Score. */
|
||||
async function RatingBlock({
|
||||
title,
|
||||
likelihood,
|
||||
impact,
|
||||
score,
|
||||
hint,
|
||||
}: {
|
||||
title: string;
|
||||
likelihood: number | null;
|
||||
impact: number | null;
|
||||
score: number | null;
|
||||
hint?: string;
|
||||
}) {
|
||||
const t = await getTranslations("risks");
|
||||
return (
|
||||
<div>
|
||||
<b>{title}</b>
|
||||
{score != null && likelihood != null && impact != null ? (
|
||||
<div className="mt-2 grid grid-cols-[1fr_auto_1fr_auto_auto] items-end gap-2">
|
||||
<div>
|
||||
<div className="text-[10.5px] leading-tight text-muted-foreground uppercase tracking-wide">
|
||||
{t("likelihoodShort")}
|
||||
</div>
|
||||
<div className="mt-0.5 font-heading text-xl font-bold leading-none">{likelihood}</div>
|
||||
</div>
|
||||
<div className="pb-0.5 text-muted-foreground">×</div>
|
||||
<div>
|
||||
<div className="text-[10.5px] leading-tight text-muted-foreground uppercase tracking-wide">
|
||||
{t("damageShort")}
|
||||
</div>
|
||||
<div className="mt-0.5 font-heading text-xl font-bold leading-none">{impact}</div>
|
||||
</div>
|
||||
<div className="pb-0.5 text-muted-foreground">=</div>
|
||||
<div className="pb-0.5">
|
||||
<ScorePill score={score} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-muted-foreground">{t("noMeasures")}</p>
|
||||
)}
|
||||
{hint && <p className="mt-1.5 text-muted-foreground">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Read-only-Risiko-Detail als Popup (SPEC §4.2 Risiko-Detailansicht). */
|
||||
export async function RiskDetailModal({
|
||||
risk,
|
||||
@@ -71,6 +129,7 @@ export async function RiskDetailModal({
|
||||
const tType = await getTranslations("assetType");
|
||||
const tTreat = await getTranslations("riskTreatment");
|
||||
const tStatus = await getTranslations("riskStatus");
|
||||
const tMStatus = await getTranslations("measureStatus");
|
||||
const tc = await getTranslations("common");
|
||||
|
||||
return (
|
||||
@@ -80,7 +139,7 @@ export async function RiskDetailModal({
|
||||
headerExtra={
|
||||
<span className="flex items-center gap-2">
|
||||
<Pill tone={STATUS_TONE[risk.status]}>{tStatus(risk.status)}</Pill>
|
||||
{await scorePill(risk.score)}
|
||||
<ScorePill score={risk.score} />
|
||||
</span>
|
||||
}
|
||||
closeHref="/risks"
|
||||
@@ -168,51 +227,70 @@ export async function RiskDetailModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Brutto- vs. Rest-Risiko */}
|
||||
{/* Brutto- vs. Rest-Risiko — mit beschrifteten Werten */}
|
||||
<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 className="grid gap-5 sm:grid-cols-2">
|
||||
<RatingBlock
|
||||
title={t("gross")}
|
||||
likelihood={risk.likelihood}
|
||||
impact={risk.impact}
|
||||
score={risk.score}
|
||||
/>
|
||||
<RatingBlock
|
||||
title={t("residual")}
|
||||
likelihood={risk.residualLikelihood}
|
||||
impact={risk.residualImpact}
|
||||
score={risk.residualScore}
|
||||
hint={t("residualAuto")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notwendige Maßnahmen — echt verknüpft */}
|
||||
<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>
|
||||
{risk.riskMeasures.length === 0 && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t("noMeasures")}</p>
|
||||
)}
|
||||
<ul className="mt-1.5 space-y-1.5 text-sm">
|
||||
{risk.riskMeasures.map((rm) => (
|
||||
<li key={rm.id} className="flex flex-wrap items-center gap-2">
|
||||
<Link href={`/measures?detail=${rm.measure.id}`} className="font-bold hover:underline">
|
||||
{measureRef(rm.measure.refNo)}
|
||||
</Link>
|
||||
<Link href={`/measures?detail=${rm.measure.id}`} className="hover:underline">
|
||||
{rm.measure.title}
|
||||
</Link>
|
||||
<Pill tone={MEASURE_STATUS_TONE[rm.measure.status]}>
|
||||
{tMStatus(rm.measure.status)}
|
||||
</Pill>
|
||||
{(rm.reductionLikelihood > 0 || rm.reductionImpact > 0) && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("reduction")}: −{rm.reductionLikelihood} {t("likelihoodShort")} / −
|
||||
{rm.reductionImpact} {t("damageShort")}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** Bearbeiten als Popup: Bewertung, Behandlung, betroffene Assets, Löschen. */
|
||||
/** Bearbeiten als Popup: Bewertung, betroffene Assets, Maßnahmen, Löschen. */
|
||||
export async function RiskEditModal({
|
||||
risk,
|
||||
users,
|
||||
processes,
|
||||
availableAssets,
|
||||
availableMeasures,
|
||||
}: {
|
||||
risk: RiskWithDetail;
|
||||
users: { id: string; name: string }[];
|
||||
processes: { id: string; name: string }[];
|
||||
availableAssets: { id: string; name: string }[];
|
||||
availableMeasures: { id: string; refNo: number; title: string }[];
|
||||
}) {
|
||||
const t = await getTranslations("risks");
|
||||
const tc = await getTranslations("common");
|
||||
@@ -236,7 +314,8 @@ export async function RiskEditModal({
|
||||
<Button type="submit">{tc("save")}</Button>
|
||||
</form>
|
||||
|
||||
<div className="space-y-4 text-sm">
|
||||
<div className="space-y-5 text-sm">
|
||||
{/* Betroffene Assets */}
|
||||
<div>
|
||||
<p className="font-medium">{t("affectedAssets")}</p>
|
||||
<p className="mb-1 text-xs text-muted-foreground">{t("affectedNote")}</p>
|
||||
@@ -257,21 +336,79 @@ export async function RiskEditModal({
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{availableAssets.length > 0 && (
|
||||
<form action={addRiskAsset.bind(null, risk.id)} className="mt-2 flex gap-2">
|
||||
<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>
|
||||
{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>
|
||||
|
||||
{/* Maßnahmen → bestimmen das Rest-Risiko */}
|
||||
<div className="border-t pt-4">
|
||||
<p className="font-medium">{t("measures")}</p>
|
||||
<p className="mb-1 text-xs text-muted-foreground">{t("residualAuto")}</p>
|
||||
{risk.riskMeasures.length === 0 && <p>{tc("none")}</p>}
|
||||
<ul className="space-y-1.5">
|
||||
{risk.riskMeasures.map((rm) => (
|
||||
<li key={rm.id} className="flex flex-wrap items-center gap-2">
|
||||
<b>{measureRef(rm.measure.refNo)}</b> {rm.measure.title}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
(−{rm.reductionLikelihood} / −{rm.reductionImpact})
|
||||
</span>
|
||||
<form action={unlinkMeasureFromRisk.bind(null, risk.id, rm.id)}>
|
||||
<button
|
||||
type="submit"
|
||||
title={tc("remove")}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{availableMeasures.length > 0 && (
|
||||
<form
|
||||
action={linkMeasureToRisk.bind(null, risk.id)}
|
||||
className="mt-3 space-y-2 rounded-lg border p-3"
|
||||
>
|
||||
<p className="text-xs font-semibold">{t("linkMeasure")}</p>
|
||||
<select name="measureId" required className={`${selectClass} w-full`}>
|
||||
{availableMeasures.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{measureRef(m.refNo)} · {m.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ReductionSelects selectClass={selectClass} />
|
||||
<Button type="submit" variant="secondary" size="sm">
|
||||
{t("linkMeasure")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<form
|
||||
action={createMeasureForRisk.bind(null, risk.id)}
|
||||
className="mt-3 space-y-2 rounded-lg border p-3"
|
||||
>
|
||||
<p className="text-xs font-semibold">{t("newMeasure")}</p>
|
||||
<Input name="title" required placeholder={t("measureTitle")} />
|
||||
<ReductionSelects selectClass={selectClass} />
|
||||
<Button type="submit" variant="secondary" size="sm">
|
||||
{t("addAsset")}
|
||||
{tc("add")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<form action={deleteRisk.bind(null, risk.id)}>
|
||||
@@ -286,6 +423,35 @@ export async function RiskEditModal({
|
||||
);
|
||||
}
|
||||
|
||||
/** Minderungs-Auswahl (Wahrscheinlichkeit/Schaden) für Maßnahmen-Verknüpfungen. */
|
||||
async function ReductionSelects({ selectClass }: { selectClass: string }) {
|
||||
const t = await getTranslations("risks");
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-[11px] text-muted-foreground">{t("reductionL")}</label>
|
||||
<select name="reductionLikelihood" defaultValue={0} className={`${selectClass} w-full`}>
|
||||
{REDUCTIONS.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
−{v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[11px] text-muted-foreground">{t("reductionI")}</label>
|
||||
<select name="reductionImpact" defaultValue={0} className={`${selectClass} w-full`}>
|
||||
{REDUCTIONS.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
−{v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Formularfelder (Anlegen + Bearbeiten teilen sich das Markup). */
|
||||
async function RiskFields({
|
||||
risk,
|
||||
@@ -363,42 +529,6 @@ async function RiskFields({
|
||||
))}
|
||||
</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
|
||||
@@ -466,21 +596,29 @@ async function RiskFields({
|
||||
);
|
||||
}
|
||||
|
||||
/** Anlegen als Popup. */
|
||||
/** Anlegen als Popup — optional mit vorverknüpftem Asset (aus dem Asset-Popup). */
|
||||
export async function RiskCreateModal({
|
||||
users,
|
||||
processes,
|
||||
preselectedAsset,
|
||||
}: {
|
||||
users: { id: string; name: string }[];
|
||||
processes: { id: string; name: string }[];
|
||||
preselectedAsset?: { id: string; name: string } | null;
|
||||
}) {
|
||||
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")}>
|
||||
<Modal
|
||||
title={t("createTitle")}
|
||||
sub={preselectedAsset ? `${t("affectedAssets")}: ${preselectedAsset.name}` : undefined}
|
||||
closeHref="/risks"
|
||||
closeLabel={t("close")}
|
||||
>
|
||||
<form action={createRisk} className="space-y-4 p-5">
|
||||
{preselectedAsset && <input type="hidden" name="assetId" value={preselectedAsset.id} />}
|
||||
<RiskFields users={users} processes={processes} selectClass={selectClass} />
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button type="submit">{tc("save")}</Button>
|
||||
|
||||
Reference in New Issue
Block a user