import Link from "next/link"; import { getFormatter, getTranslations } from "next-intl/server"; import { ArrowRight, Check, Pencil, Plus, Trash2, X } from "lucide-react"; import type { Prisma } from "@prisma/client"; import { addRiskAsset, createRisk, deleteRisk, 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"; 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: { 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; }; }; }; }; riskMeasures: { include: { measure: { select: { id: true; refNo: true; title: true; status: 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 }: { score: number }) { const tLevel = await getTranslations("riskLevel"); const format = await getFormatter(); const level = riskLevel(score); return ( {format.number(score, { maximumFractionDigits: 2 })} · {tLevel(level)} ); } /** Bewertungs-Karte: große Score-Zahl, darunter Wahrscheinlichkeit × Schaden + Level-Chip. */ async function RatingCard({ title, likelihood, impact, score, accent, }: { title: string; likelihood: number | null; impact: number | null; score: number | null; 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 (
{title}
{score != null && likelihood != null && impact != null ? ( <>
{num(score)}
{t("likelihoodShort")} {num(likelihood)} × {t("damageShort")} {num(impact)}
) : (

{t("noMeasures")}

)} {accent === "residual" && (

{t("residualAuto")}

)}
); } /** 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 (
{residual != null && ( )}
Brutto Rest
); } /** 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 tMStatus = await getTranslations("measureStatus"); const tc = await getTranslations("common"); return ( {tStatus(risk.status)} } closeHref="/risks" closeLabel={t("close")} footer={ <> {canWrite && ( )} } >
{risk.title} {risk.description && (

{risk.description}

)}
{t("threat")}
{risk.threat ?? tc("none")}
{t("vulnerability")}
{risk.vulnerability ?? tc("none")}
{t("owner")}
{risk.owner?.name ?? tc("none")}
{t("treatment")}
{tTreat(risk.treatment)}
{t("process")}
{risk.process ? ( {risk.process.name} ) : ( tc("none") )}
{t("affectedAssets")} {t("affectedNote")}
{risk.riskAssets.length === 0 && (

{t("noAssets")}

)} {risk.riskAssets.map((ra) => ( ))}
{ra.asset.name} {tType(ra.asset.type)}
{/* Brutto → Rest-Risiko: zwei Karten mit Pfeil + Farbskala */}
{/* Notwendige Maßnahmen — echt verknüpft */}

{t("measures")}

{risk.riskMeasures.length === 0 && (

{t("noMeasures")}

)}
    {risk.riskMeasures.map((rm) => (
  • {measureRef(rm.measure.refNo)} {rm.measure.title} {tMStatus(rm.measure.status)} {(rm.reductionLikelihood > 0 || rm.reductionImpact > 0) && ( )}
  • ))}
); } /** Bearbeiten als Popup: Bewertung, betroffene Assets, Maßnahmen, Löschen. */ export async function RiskEditModal({ risk, users, processes, availableAssets, availableMeasures, threats, vulnerabilities, }: { risk: RiskWithDetail; users: { id: string; name: string }[]; processes: { id: string; name: string }[]; availableAssets: { id: string; name: string }[]; availableMeasures: { id: string; refNo: number; title: string }[]; threats: string[]; vulnerabilities: 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 (
{/* Betroffene Assets */}

{t("affectedAssets")}

{t("affectedNote")}

{risk.riskAssets.length === 0 &&

{tc("none")}

}
    {risk.riskAssets.map((ra) => (
  • {ra.asset.name}
  • ))}
{availableAssets.length > 0 && (
)}
{/* Maßnahmen → bestimmen das Rest-Risiko (volle Breite unter den Stammdaten) */}

{t("measures")}

{t("residualAuto")}

{/* Aufklappbare Formulare per
— serverseitig, kein Client-State */} {availableMeasures.length > 0 && (
{t("addExistingBtn")}

{t("linkMeasure")}

)}
{t("createNewBtn")}

{t("newMeasure")}

{risk.riskMeasures.length === 0 && (

{tc("none")}

)} {risk.riskMeasures.length > 0 && (
{t("id")} {t("measureCol")} {t("reductionL")} {t("reductionI")}
{risk.riskMeasures.map((rm) => (
{measureRef(rm.measure.refNo)} {rm.measure.title}
))}
)}
); } /** Anzeige einer Minderung mit lokalisierten Dezimalzahlen. */ async function ReductionLabel({ l, i }: { l: number; i: number }) { const t = await getTranslations("risks"); const format = await getFormatter(); const num = (v: number) => format.number(v, { maximumFractionDigits: 2 }); return ( {t("reduction")}: −{num(l)} {t("likelihoodShort")} / −{num(i)} {t("damageShort")} ); } /** Minderungs-Eingaben (dezimal, 0,00–4,00) für Maßnahmen-Verknüpfungen. */ async function ReductionInputs({ defaultL = 0, defaultI = 0, }: { defaultL?: number; defaultI?: number; }) { const t = await getTranslations("risks"); return (
); } /** Formularfelder (Anlegen + Bearbeiten teilen sich das Markup). */ async function RiskFields({ risk, users, processes, threats, vulnerabilities, selectClass, }: { risk?: RiskWithDetail; users: { id: string; name: string }[]; processes: { id: string; name: string }[]; threats: string[]; vulnerabilities: string[]; selectClass: string; }) { const t = await getTranslations("risks"); const tTreat = await getTranslations("riskTreatment"); const tStatus = await getTranslations("riskStatus"); const tc = await getTranslations("common"); return ( <>