Risiko-Bearbeiten: Maßnahmen in voller Breite, Brutto→Rest visualisiert

- Maßnahmen-Bereich unterhalb der Stammdaten über die komplette Breite
  (Tabelle mit ID, Titel, editierbarer Minderung je Dimension)
- Zwei Aktionen als aufklappbare Buttons: "Maßnahme hinzufügen"
  (bestehende auswählen) und "Neue Maßnahme erstellen" (legt an und
  verknüpft direkt mit dem Risiko, Rest-Risiko wird neu berechnet)
- Risiko-Detail: Brutto→Rest als zwei farbig abgesetzte Karten (rot/
  orange) mit Pfeil, großer Score-Zahl und Level-Chip; darunter
  Farbskala (grün→rot) mit Markern für Brutto und Rest (Referenz:
  docs/ISMS-GUI-Verbesserungen-GEFIM.html)

Verifiziert: neue Maßnahme -0,75 → Rest 1,75 × 4 = 7; Skala/Karten
korrekt gerendert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Martin
2026-07-02 19:46:37 +02:00
co-authored by Claude Opus 4.8
parent dfe27de51f
commit 5fb4b3cbf4
4 changed files with 578 additions and 107 deletions
+186 -105
View File
@@ -1,6 +1,6 @@
import Link from "next/link";
import { getFormatter, getTranslations } from "next-intl/server";
import { Check, Pencil, Trash2, X } from "lucide-react";
import { ArrowRight, Check, Pencil, Plus, Trash2, X } from "lucide-react";
import type { Prisma } from "@prisma/client";
import {
addRiskAsset,
@@ -71,50 +71,87 @@ async function ScorePill({ score }: { score: number }) {
);
}
/** Bewertungs-Block: Wahrscheinlichkeit & Schaden mit Beschriftung + Score. */
async function RatingBlock({
/** Bewertungs-Karte: große Score-Zahl, darunter Wahrscheinlichkeit × Schaden + Level-Chip. */
async function RatingCard({
title,
likelihood,
impact,
score,
hint,
accent,
}: {
title: string;
likelihood: number | null;
impact: number | null;
score: number | null;
hint?: string;
accent: "gross" | "residual";
}) {
const t = await getTranslations("risks");
const format = await getFormatter();
const num = (v: number) => format.number(v, { maximumFractionDigits: 2 });
const border = accent === "gross" ? "border-l-[#d64c4c]" : "border-l-[#e0982e]";
return (
<div>
<b>{title}</b>
<div className={`rounded-xl border border-l-[3px] bg-white p-4 ${border}`}>
<div className="text-[11px] font-bold tracking-wide text-muted-foreground uppercase">
{title}
</div>
{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">{num(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">{num(impact)}</div>
</div>
<div className="pb-0.5 text-muted-foreground">=</div>
<div className="pb-0.5">
<>
<div className="mt-1 flex items-baseline gap-2">
<span className="font-heading text-3xl font-bold leading-none text-foreground">
{num(score)}
</span>
<ScorePill score={score} />
</div>
</div>
<div className="mt-1.5 text-[12px] text-muted-foreground">
{t("likelihoodShort")} {num(likelihood)} × {t("damageShort")} {num(impact)}
</div>
</>
) : (
<p className="mt-2 text-muted-foreground">{t("noMeasures")}</p>
<p className="mt-1.5 text-[12px] text-muted-foreground">{t("noMeasures")}</p>
)}
{hint && <p className="mt-1.5 text-muted-foreground">{hint}</p>}
{accent === "residual" && (
<p className="mt-2 text-[11.5px] text-muted-foreground">{t("residualAuto")}</p>
)}
</div>
);
}
/** Farbskala grün→rot mit Markern für Brutto (dunkel) und Rest (orange). SPEC §4.2. */
function RiskScoreScale({ gross, residual }: { gross: number; residual: number | null }) {
// 5×5 → Score 1..25; Position linear auf der Skala
const pos = (v: number) => `${((Math.min(25, Math.max(1, v)) - 1) / 24) * 100}%`;
return (
<div className="mt-4">
<div
className="relative h-3 rounded-lg"
style={{
background:
"linear-gradient(90deg,#dff3e5,#fdf0cf,#fbdcc0,#f6c3c1,#ef9a98)",
}}
>
<span
className="absolute -top-1 h-5 w-[3px] -translate-x-1/2 rounded-sm bg-[#2b2646]"
style={{ left: pos(gross) }}
title={`Brutto ${gross}`}
/>
{residual != null && (
<span
className="absolute -top-1 h-5 w-[3px] -translate-x-1/2 rounded-sm bg-[#e0982e]"
style={{ left: pos(residual) }}
title={`Rest ${residual}`}
/>
)}
</div>
<div className="mt-1.5 flex items-center gap-4 text-[11px] text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<i className="inline-block h-3 w-[3px] rounded-sm bg-[#2b2646]" /> Brutto
</span>
<span className="inline-flex items-center gap-1.5">
<i className="inline-block h-3 w-[3px] rounded-sm bg-[#e0982e]" /> Rest
</span>
</div>
</div>
);
}
@@ -229,23 +266,26 @@ export async function RiskDetailModal({
</div>
</div>
{/* 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-5 sm:grid-cols-2">
<RatingBlock
{/* Brutto Rest-Risiko: zwei Karten mit Pfeil + Farbskala */}
<div className="mx-5 rounded-xl border border-[#e6e2f3] bg-[#f2f0f9] p-4">
<div className="grid items-center gap-3 sm:grid-cols-[1fr_auto_1fr]">
<RatingCard
title={t("gross")}
likelihood={risk.likelihood}
impact={risk.impact}
score={risk.score}
accent="gross"
/>
<RatingBlock
<ArrowRight className="mx-auto size-5 rotate-90 text-muted-foreground sm:rotate-0" />
<RatingCard
title={t("residual")}
likelihood={risk.residualLikelihood}
impact={risk.residualImpact}
score={risk.residualScore}
hint={t("residualAuto")}
accent="residual"
/>
</div>
<RiskScoreScale gross={risk.score} residual={risk.residualScore} />
</div>
{/* Notwendige Maßnahmen — echt verknüpft */}
@@ -362,89 +402,130 @@ export async function RiskEditModal({
)}
</div>
{/* 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-2">
{risk.riskMeasures.map((rm) => (
<li key={rm.id} className="rounded-lg border p-2.5">
<div className="flex items-center gap-2">
<b>{measureRef(rm.measure.refNo)}</b>
<span className="min-w-0 flex-1 truncate">{rm.measure.title}</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>
</div>
{/* Minderung nachträglich anpassen (upsert über linkMeasureToRisk) */}
<form
key={rm.reductionLikelihood + "-" + rm.reductionImpact}
action={linkMeasureToRisk.bind(null, risk.id)}
className="mt-2 flex items-end gap-2"
>
<input type="hidden" name="measureId" value={rm.measureId} />
<div className="flex-1">
<ReductionInputs
defaultL={rm.reductionLikelihood}
defaultI={rm.reductionImpact}
/>
</div>
<Button type="submit" variant="secondary" size="icon-sm" title={tc("save")}>
<Check className="size-3.5" />
</Button>
</form>
</li>
))}
</ul>
</div>
</div>
{/* Maßnahmen → bestimmen das Rest-Risiko (volle Breite unter den Stammdaten) */}
<div className="border-t p-5 text-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="font-heading text-[15px] font-semibold">{t("measures")}</p>
<p className="text-xs text-muted-foreground">{t("residualAuto")}</p>
</div>
<div className="flex gap-2">
{/* Aufklappbare Formulare per <details> — serverseitig, kein Client-State */}
{availableMeasures.length > 0 && (
<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" /> {t("addExistingBtn")}
</summary>
<form
action={linkMeasureToRisk.bind(null, risk.id)}
className="shadow-card absolute right-0 z-10 mt-2 w-80 space-y-2 rounded-xl border bg-card 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>
<ReductionInputs />
<Button type="submit" variant="secondary" size="sm">
{t("linkMeasure")}
</Button>
</form>
</details>
)}
<details className="relative">
<summary className="inline-flex h-8 cursor-pointer list-none items-center gap-1.5 rounded-lg border border-border bg-background px-2.5 font-heading text-sm font-semibold select-none hover:bg-muted [&::-webkit-details-marker]:hidden">
<Plus className="size-4" /> {t("createNewBtn")}
</summary>
<form
action={linkMeasureToRisk.bind(null, risk.id)}
className="mt-3 space-y-2 rounded-lg border p-3"
action={createMeasureForRisk.bind(null, risk.id)}
className="shadow-card absolute right-0 z-10 mt-2 w-80 space-y-2 rounded-xl border bg-card 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>
<p className="text-xs font-semibold">{t("newMeasure")}</p>
<Input name="title" required placeholder={t("measureTitle")} />
<ReductionInputs />
<Button type="submit" variant="secondary" size="sm">
{t("linkMeasure")}
{tc("add")}
</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")} />
<ReductionInputs />
<Button type="submit" variant="secondary" size="sm">
{tc("add")}
</Button>
</form>
</div>
<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>
</details>
</div>
</div>
{risk.riskMeasures.length === 0 && (
<p className="mt-3 text-muted-foreground">{tc("none")}</p>
)}
{risk.riskMeasures.length > 0 && (
<div className="mt-3 overflow-hidden rounded-xl border">
<div className="grid grid-cols-[7rem_1fr_11rem_11rem_auto_auto] items-center gap-x-3 border-b bg-muted/60 px-3 py-2 text-[11px] font-bold tracking-[.04em] text-muted-foreground uppercase max-md:hidden">
<span>{t("id")}</span>
<span>{t("measureCol")}</span>
<span>{t("reductionL")}</span>
<span>{t("reductionI")}</span>
<span />
<span />
</div>
{risk.riskMeasures.map((rm) => (
<form
key={rm.id + "-" + rm.reductionLikelihood + "-" + rm.reductionImpact}
action={linkMeasureToRisk.bind(null, risk.id)}
className="grid grid-cols-1 items-center gap-2 border-b px-3 py-2.5 last:border-0 md:grid-cols-[7rem_1fr_11rem_11rem_auto_auto] md:gap-x-3"
>
<input type="hidden" name="measureId" value={rm.measureId} />
<b>{measureRef(rm.measure.refNo)}</b>
<span className="min-w-0 truncate" title={rm.measure.title}>
{rm.measure.title}
</span>
<Input
name="reductionLikelihood"
type="number"
min={0}
max={4}
step={0.05}
defaultValue={rm.reductionLikelihood}
aria-label={t("reductionL")}
className="h-8"
/>
<Input
name="reductionImpact"
type="number"
min={0}
max={4}
step={0.05}
defaultValue={rm.reductionImpact}
aria-label={t("reductionI")}
className="h-8"
/>
<Button type="submit" variant="secondary" size="icon-sm" title={tc("save")}>
<Check className="size-3.5" />
</Button>
<Button
type="submit"
variant="ghost"
size="icon-sm"
title={tc("remove")}
formAction={unlinkMeasureFromRisk.bind(null, risk.id, rm.id)}
className="text-muted-foreground hover:text-destructive"
>
<X className="size-3.5" />
</Button>
</form>
))}
</div>
)}
</div>
<div className="border-t p-5">
<form action={deleteRisk.bind(null, risk.id)}>
<Button type="submit" variant="destructive" size="sm">
<Trash2 className="size-4" /> {tc("delete")}
</Button>
</form>
</div>
</Modal>
);