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 && (
}
>
{tc("edit")}
)}
}>
{t("close")}
>
}
>
{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("createNewBtn")}
{risk.riskMeasures.length === 0 && (
{tc("none")}
)}
{risk.riskMeasures.length > 0 && (
{t("id")}
{t("measureCol")}
{t("reductionL")}
{t("reductionI")}
{risk.riskMeasures.map((rm) => (
))}
)}
);
}
/** 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 (
<>
>
);
}
/** Anlegen als Popup — optional mit vorverknüpftem Asset (aus dem Asset-Popup). */
export async function RiskCreateModal({
users,
processes,
preselectedAsset,
threats,
vulnerabilities,
}: {
users: { id: string; name: string }[];
processes: { id: string; name: string }[];
preselectedAsset?: { id: string; name: string } | null;
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 (
);
}