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:
co-authored by
Claude Opus 4.8
parent
dfe27de51f
commit
5fb4b3cbf4
File diff suppressed because one or more lines are too long
+5
-1
@@ -256,7 +256,11 @@
|
||||
"reductionI": "Minderung Schaden (0,00–4,00)",
|
||||
"reduction": "Minderung",
|
||||
"createFromAsset": "Risiko erstellen",
|
||||
"catalogHint": "Aus Katalog wählen oder frei eingeben …"
|
||||
"catalogHint": "Aus Katalog wählen oder frei eingeben …",
|
||||
"addExistingBtn": "Maßnahme hinzufügen",
|
||||
"createNewBtn": "Neue Maßnahme erstellen",
|
||||
"measureCol": "Maßnahme",
|
||||
"statusCol": "Status"
|
||||
},
|
||||
"riskTreatment": {
|
||||
"AVOID": "Vermeiden",
|
||||
|
||||
+5
-1
@@ -256,7 +256,11 @@
|
||||
"reductionI": "Reduction damage (0.00–4.00)",
|
||||
"reduction": "Reduction",
|
||||
"createFromAsset": "Create risk",
|
||||
"catalogHint": "Pick from catalog or type freely …"
|
||||
"catalogHint": "Pick from catalog or type freely …",
|
||||
"addExistingBtn": "Add measure",
|
||||
"createNewBtn": "Create new measure",
|
||||
"measureCol": "Measure",
|
||||
"statusCol": "Status"
|
||||
},
|
||||
"riskTreatment": {
|
||||
"AVOID": "Avoid",
|
||||
|
||||
+157
-76
@@ -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 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,52 +402,26 @@ 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>
|
||||
|
||||
{/* 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="mt-3 space-y-2 rounded-lg border p-3"
|
||||
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`}>
|
||||
@@ -422,11 +436,15 @@ export async function RiskEditModal({
|
||||
{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={createMeasureForRisk.bind(null, risk.id)}
|
||||
className="mt-3 space-y-2 rounded-lg border p-3"
|
||||
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("newMeasure")}</p>
|
||||
<Input name="title" required placeholder={t("measureTitle")} />
|
||||
@@ -435,17 +453,80 @@ export async function RiskEditModal({
|
||||
{tc("add")}
|
||||
</Button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user