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:
Martin
2026-07-02 16:07:59 +02:00
co-authored by Claude Fable 5
parent df634e356d
commit 668e587881
20 changed files with 1534 additions and 102 deletions
+47 -1
View File
@@ -243,7 +243,19 @@
"legendHigh": "Hoch",
"legendCritical": "Kritisch",
"axisX": "Eintrittswahrscheinlichkeit →",
"axisY": "Auswirkung ↑"
"axisY": "Auswirkung ↑",
"likelihoodShort": "Eintrittswahrscheinlichkeit",
"damageShort": "Schaden",
"scoreShort": "Risikowert",
"residualAuto": "Ergibt sich automatisch aus den verknüpften Maßnahmen.",
"noMeasures": "Noch keine Maßnahmen verknüpft — das Rest-Risiko entspricht dem Brutto-Risiko.",
"linkMeasure": "Maßnahme verknüpfen",
"newMeasure": "Neue Maßnahme anlegen & verknüpfen",
"measureTitle": "Titel der Maßnahme",
"reductionL": "Minderung Wahrscheinlichkeit (04)",
"reductionI": "Minderung Schaden (04)",
"reduction": "Minderung",
"createFromAsset": "Risiko erstellen"
},
"riskTreatment": {
"AVOID": "Vermeiden",
@@ -263,5 +275,39 @@
"elevated": "Erhöht",
"high": "Hoch",
"critical": "Kritisch"
},
"measures": {
"title": "Aufgaben & Maßnahmen",
"sub": "Kanban-Board · Maßnahmen aus Risiken, Audits und Vorfällen",
"crumb": "Betrieb",
"newMeasure": "Maßnahme",
"board": "Maßnahmen-Board",
"empty": "Keine Maßnahmen.",
"detailHeading": "Maßnahme · {ref}",
"detailSub": "Status, Verantwortung & verknüpfte Risiken",
"editTitle": "Maßnahme bearbeiten",
"createTitle": "Maßnahme anlegen",
"titleField": "Titel",
"description": "Beschreibung",
"status": "Status",
"priority": "Priorität",
"owner": "Verantwortlich",
"dueDate": "Fällig am",
"linkedRisks": "Verknüpfte Risiken",
"linkedRisksNote": "Diese Maßnahme mindert folgende Risiken",
"noRisks": "Keine Risiken verknüpft.",
"close": "Schließen",
"overdue": "überfällig",
"dragHint": "Karten per Drag-and-Drop zwischen den Spalten verschieben."
},
"measureStatus": {
"OPEN": "Offen",
"IN_PROGRESS": "In Umsetzung",
"DONE": "Erledigt"
},
"measurePriority": {
"LOW": "Niedrig",
"MEDIUM": "Mittel",
"HIGH": "Hoch"
}
}
+47 -1
View File
@@ -243,7 +243,19 @@
"legendHigh": "High",
"legendCritical": "Critical",
"axisX": "Likelihood →",
"axisY": "Impact ↑"
"axisY": "Impact ↑",
"likelihoodShort": "Likelihood",
"damageShort": "Damage",
"scoreShort": "Risk score",
"residualAuto": "Derived automatically from the linked measures.",
"noMeasures": "No measures linked yet — the residual risk equals the gross risk.",
"linkMeasure": "Link measure",
"newMeasure": "Create & link new measure",
"measureTitle": "Measure title",
"reductionL": "Reduction likelihood (04)",
"reductionI": "Reduction damage (04)",
"reduction": "Reduction",
"createFromAsset": "Create risk"
},
"riskTreatment": {
"AVOID": "Avoid",
@@ -263,5 +275,39 @@
"elevated": "Elevated",
"high": "High",
"critical": "Critical"
},
"measures": {
"title": "Tasks & measures",
"sub": "Kanban board · measures from risks, audits and incidents",
"crumb": "Operations",
"newMeasure": "Measure",
"board": "Measures board",
"empty": "No measures.",
"detailHeading": "Measure · {ref}",
"detailSub": "Status, responsibility & linked risks",
"editTitle": "Edit measure",
"createTitle": "Create measure",
"titleField": "Title",
"description": "Description",
"status": "Status",
"priority": "Priority",
"owner": "Responsible",
"dueDate": "Due date",
"linkedRisks": "Linked risks",
"linkedRisksNote": "This measure reduces the following risks",
"noRisks": "No risks linked.",
"close": "Close",
"overdue": "overdue",
"dragHint": "Drag cards between columns to change status."
},
"measureStatus": {
"OPEN": "Open",
"IN_PROGRESS": "In progress",
"DONE": "Done"
},
"measurePriority": {
"LOW": "Low",
"MEDIUM": "Medium",
"HIGH": "High"
}
}
+40
View File
@@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"@base-ui/react": "^1.6.0",
"@dnd-kit/core": "^6.3.1",
"@node-rs/argon2": "^2.0.2",
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
@@ -555,6 +556,45 @@
}
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dotenvx/dotenvx": {
"version": "1.75.1",
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.75.1.tgz",
+1
View File
@@ -10,6 +10,7 @@
},
"dependencies": {
"@base-ui/react": "^1.6.0",
"@dnd-kit/core": "^6.3.1",
"@node-rs/argon2": "^2.0.2",
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
@@ -0,0 +1,59 @@
-- CreateEnum
CREATE TYPE "MeasureStatus" AS ENUM ('OPEN', 'IN_PROGRESS', 'DONE');
-- CreateEnum
CREATE TYPE "MeasurePriority" AS ENUM ('LOW', 'MEDIUM', 'HIGH');
-- CreateTable
CREATE TABLE "measures" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"ref_no" INTEGER NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"status" "MeasureStatus" NOT NULL DEFAULT 'OPEN',
"priority" "MeasurePriority" NOT NULL DEFAULT 'MEDIUM',
"owner_id" TEXT,
"due_date" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"created_by" TEXT,
CONSTRAINT "measures_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "risk_measures" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"risk_id" TEXT NOT NULL,
"measure_id" TEXT NOT NULL,
"reduction_likelihood" INTEGER NOT NULL DEFAULT 0,
"reduction_impact" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "risk_measures_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "measures_tenant_id_idx" ON "measures"("tenant_id");
-- CreateIndex
CREATE INDEX "measures_tenant_id_status_idx" ON "measures"("tenant_id", "status");
-- CreateIndex
CREATE UNIQUE INDEX "measures_tenant_id_ref_no_key" ON "measures"("tenant_id", "ref_no");
-- CreateIndex
CREATE INDEX "risk_measures_tenant_id_idx" ON "risk_measures"("tenant_id");
-- CreateIndex
CREATE UNIQUE INDEX "risk_measures_risk_id_measure_id_key" ON "risk_measures"("risk_id", "measure_id");
-- AddForeignKey
ALTER TABLE "measures" ADD CONSTRAINT "measures_owner_id_fkey" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "risk_measures" ADD CONSTRAINT "risk_measures_risk_id_fkey" FOREIGN KEY ("risk_id") REFERENCES "risks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "risk_measures" ADD CONSTRAINT "risk_measures_measure_id_fkey" FOREIGN KEY ("measure_id") REFERENCES "measures"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,9 @@
-- RLS-Policies für die Maßnahmen-Tabellen
ALTER TABLE "measures" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "risk_measures" ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON "measures"
USING ("tenant_id" = current_setting('app.tenant_id', true));
CREATE POLICY tenant_isolation ON "risk_measures"
USING ("tenant_id" = current_setting('app.tenant_id', true));
+65 -3
View File
@@ -61,6 +61,7 @@ model User {
ownedAssets Asset[] @relation("assetOwner")
ownedProcesses Process[] @relation("processOwner")
ownedRisks Risk[] @relation("riskOwner")
ownedMeasures Measure[] @relation("measureOwner")
@@unique([tenantId, email])
@@index([tenantId])
@@ -292,9 +293,10 @@ model Risk {
updatedAt DateTime @updatedAt @map("updated_at")
createdBy String? @map("created_by")
owner User? @relation("riskOwner", fields: [ownerId], references: [id])
process Process? @relation(fields: [processId], references: [id])
riskAssets RiskAsset[]
owner User? @relation("riskOwner", fields: [ownerId], references: [id])
process Process? @relation(fields: [processId], references: [id])
riskAssets RiskAsset[]
riskMeasures RiskMeasure[]
@@unique([tenantId, refNo])
@@index([tenantId])
@@ -316,6 +318,66 @@ model RiskAsset {
@@map("risk_assets")
}
// ── Maßnahmen (SPEC §4.4) ───────────────────────────────────────────────────
enum MeasureStatus {
OPEN
IN_PROGRESS
DONE
}
enum MeasurePriority {
LOW
MEDIUM
HIGH
}
model Measure {
id String @id @default(cuid())
tenantId String @map("tenant_id")
refNo Int @map("ref_no") // Anzeige als "M-001"
title String
description String?
status MeasureStatus @default(OPEN)
priority MeasurePriority @default(MEDIUM)
ownerId String? @map("owner_id")
dueDate DateTime? @map("due_date")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
createdBy String? @map("created_by")
owner User? @relation("measureOwner", fields: [ownerId], references: [id])
riskMeasures RiskMeasure[]
@@unique([tenantId, refNo])
@@index([tenantId])
@@index([tenantId, status])
@@map("measures")
}
// Verknüpfung Risiko ↔ Maßnahme inkl. erwarteter Risikominderung:
// Das Rest-Risiko wird aus likelihood/impact minus der Summe der
// Minderungen aller verknüpften Maßnahmen berechnet (siehe src/server/risk-calc.ts).
model RiskMeasure {
id String @id @default(cuid())
tenantId String @map("tenant_id")
riskId String @map("risk_id")
measureId String @map("measure_id")
// Minderung in Stufen (04) je Dimension
reductionLikelihood Int @default(0) @map("reduction_likelihood")
reductionImpact Int @default(0) @map("reduction_impact")
risk Risk @relation(fields: [riskId], references: [id], onDelete: Cascade)
measure Measure @relation(fields: [measureId], references: [id], onDelete: Cascade)
@@unique([riskId, measureId])
@@index([tenantId])
@@map("risk_measures")
}
model AuditLog {
id String @id @default(cuid())
tenantId String @map("tenant_id")
+104
View File
@@ -303,6 +303,110 @@ async function main() {
}
console.log(`${risks.length} Beispiel-Risiken angelegt`);
}
// 7. Beispiel-Maßnahmen (nur wenn noch keine existieren) — bestimmen das Rest-Risiko
const measureCount = await prisma.measure.count({ where: { tenantId: tenant.id } });
if (measureCount === 0) {
const owner = await prisma.user.findUniqueOrThrow({
where: { tenantId_email: { tenantId: tenant.id, email: "owner@demo.example" } },
});
const riskByTitle = (title: string) =>
prisma.risk.findFirst({ where: { tenantId: tenant.id, title } });
const measures: {
title: string;
status: "OPEN" | "IN_PROGRESS" | "DONE";
priority: "LOW" | "MEDIUM" | "HIGH";
dueInDays?: number;
riskTitle?: string;
reduction?: [number, number]; // [Wahrscheinlichkeit, Schaden]
}[] = [
{
title: "Offline-Backups einführen (3-2-1-Regel)",
status: "IN_PROGRESS",
priority: "HIGH",
dueInDays: 14,
riskTitle: "Ransomware auf ERP",
reduction: [1, 1],
},
{
title: "Makro-Ausführung per GPO einschränken",
status: "OPEN",
priority: "HIGH",
dueInDays: 30,
riskTitle: "Ransomware auf ERP",
reduction: [1, 0],
},
{
title: "Awareness-Schulung Phishing (alle Mitarbeitenden)",
status: "IN_PROGRESS",
priority: "MEDIUM",
dueInDays: 45,
riskTitle: "Phishing / Social Engineering",
reduction: [1, 0],
},
{
title: "Notfallhandbuch aktualisieren",
status: "DONE",
priority: "MEDIUM",
},
];
let mRef = 1;
for (const m of measures) {
const measure = await prisma.measure.create({
data: {
tenantId: tenant.id,
refNo: mRef++,
title: m.title,
status: m.status,
priority: m.priority,
ownerId: owner.id,
dueDate: m.dueInDays
? new Date(Date.now() + m.dueInDays * 24 * 3600 * 1000)
: undefined,
},
});
if (m.riskTitle && m.reduction) {
const risk = await riskByTitle(m.riskTitle);
if (risk) {
await prisma.riskMeasure.create({
data: {
tenantId: tenant.id,
riskId: risk.id,
measureId: measure.id,
reductionLikelihood: m.reduction[0],
reductionImpact: m.reduction[1],
},
});
}
}
}
// Rest-Risiken aus den Maßnahmen ableiten (manuelle Alt-Werte ersetzen)
const allRisks = await prisma.risk.findMany({
where: { tenantId: tenant.id },
include: { riskMeasures: true },
});
for (const r of allRisks) {
if (r.riskMeasures.length === 0) {
await prisma.risk.update({
where: { id: r.id },
data: { residualLikelihood: null, residualImpact: null, residualScore: null },
});
} else {
const redL = r.riskMeasures.reduce((s, x) => s + x.reductionLikelihood, 0);
const redI = r.riskMeasures.reduce((s, x) => s + x.reductionImpact, 0);
const rl = Math.max(1, r.likelihood - redL);
const ri = Math.max(1, r.impact - redI);
await prisma.risk.update({
where: { id: r.id },
data: { residualLikelihood: rl, residualImpact: ri, residualScore: rl * ri },
});
}
}
console.log(`${measures.length} Beispiel-Maßnahmen angelegt, Rest-Risiken berechnet`);
}
}
main()
+1 -1
View File
@@ -37,7 +37,7 @@ export default async function AppLayout({
{ href: "/processes", label: t("bia"), icon: GitBranch, enabled: true },
{ href: "/risks", label: t("risks"), icon: ShieldAlert, enabled: true },
{ href: "/soa", label: t("soa"), icon: ClipboardCheck, enabled: false },
{ href: "/measures", label: t("measures"), icon: KanbanSquare, enabled: false },
{ href: "/measures", label: t("measures"), icon: KanbanSquare, enabled: true },
{ href: "/incidents", label: t("incidents"), icon: Siren, enabled: false },
{ href: "/policies", label: t("policies"), icon: BookOpenText, enabled: false },
{ href: "/chat", label: t("chat"), icon: MessagesSquare, enabled: false },
+133
View File
@@ -0,0 +1,133 @@
import Link from "next/link";
import { getFormatter, getTranslations } from "next-intl/server";
import { Plus } from "lucide-react";
import { requireSession } from "@/server/auth";
import { dbForTenant } from "@/server/db";
import { hasPermission, requirePermission } from "@/server/rbac";
import { updateMeasureStatus } from "@/server/actions/measures";
import { Button } from "@/components/ui/button";
import { PageHead } from "@/components/mockup-ui";
import { KanbanBoard, type KanbanColumn } from "@/components/kanban-board";
import {
MeasureCreateModal,
MeasureDetailModal,
MeasureEditModal,
} from "@/components/measure-modals";
import { measureRef } from "@/lib/measure";
const MEASURE_INCLUDE = {
owner: { select: { id: true, name: true } },
riskMeasures: {
include: { risk: { select: { id: true, refNo: true, title: true, score: true } } },
},
} as const;
const PRIORITY_CLASS = {
LOW: "bg-[#eef0f4] text-[#7a7b86]",
MEDIUM: "bg-[#e7f1fa] text-[#3a86c8]",
HIGH: "bg-[#fbe7e7] text-[#d64c4c]",
} as const;
export default async function MeasuresPage({
searchParams,
}: {
searchParams: Promise<{ detail?: string; edit?: string; new?: string }>;
}) {
const session = await requireSession();
requirePermission(session, "measure:read");
const t = await getTranslations("measures");
const tStatus = await getTranslations("measureStatus");
const tPrio = await getTranslations("measurePriority");
const format = await getFormatter();
const params = await searchParams;
const db = dbForTenant(session.user.tenantId);
const canWrite = hasPermission(session, "measure:write");
const measures = await db.measure.findMany({
include: {
owner: { select: { name: true } },
_count: { select: { riskMeasures: true } },
},
orderBy: [{ dueDate: { sort: "asc", nulls: "last" } }, { refNo: "asc" }],
take: 300,
});
const modalId = params.edit && canWrite ? params.edit : params.detail;
const modalMeasure = modalId
? await db.measure.findUnique({ where: { id: modalId }, include: MEASURE_INCLUDE })
: null;
const users =
canWrite && (params.edit || params.new)
? await db.user.findMany({
where: { status: "ACTIVE" },
select: { id: true, name: true },
orderBy: { name: "asc" },
})
: [];
const now = new Date();
const initials = (name?: string | null) =>
name
? name
.split(/\s+/)
.map((p) => p[0])
.slice(0, 2)
.join("")
.toUpperCase()
: null;
const columns: KanbanColumn[] = (["OPEN", "IN_PROGRESS", "DONE"] as const).map((status) => ({
status,
label: tStatus(status),
cards: measures
.filter((m) => m.status === status)
.map((m) => ({
id: m.id,
refLabel: measureRef(m.refNo),
title: m.title,
dueLabel: m.dueDate ? format.dateTime(m.dueDate, { dateStyle: "medium" }) : null,
overdue: !!m.dueDate && m.dueDate < now && m.status !== "DONE",
priorityLabel: tPrio(m.priority),
priorityClass: PRIORITY_CLASS[m.priority],
ownerInitials: initials(m.owner?.name),
riskCount: m._count.riskMeasures,
})),
}));
return (
<main className="flex-1 p-6">
<PageHead
crumb={t("crumb")}
title={t("title")}
sub={t("sub")}
actions={
canWrite && (
<Button nativeButton={false} render={<Link href="/measures?new=1" />}>
<Plus className="size-4" /> {t("newMeasure")}
</Button>
)
}
/>
{canWrite && (
<p className="mb-3 text-[12.5px] text-muted-foreground">{t("dragHint")}</p>
)}
<KanbanBoard columns={columns} canWrite={canWrite} onMove={updateMeasureStatus} />
{measures.length === 0 && (
<p className="mt-6 text-center text-sm text-muted-foreground">{t("empty")}</p>
)}
{modalMeasure && params.edit && canWrite ? (
<MeasureEditModal measure={modalMeasure} users={users} />
) : modalMeasure ? (
<MeasureDetailModal measure={modalMeasure} canWrite={canWrite} />
) : params.new && canWrite ? (
<MeasureCreateModal users={users} />
) : null}
</main>
);
}
+26 -4
View File
@@ -38,6 +38,11 @@ const RISK_INCLUDE = {
},
},
},
riskMeasures: {
include: {
measure: { select: { id: true, refNo: true, title: true, status: true } },
},
},
} as const;
const STATUS_TONE = {
@@ -50,7 +55,7 @@ const STATUS_TONE = {
export default async function RisksPage({
searchParams,
}: {
searchParams: Promise<{ detail?: string; edit?: string; new?: string }>;
searchParams: Promise<{ detail?: string; edit?: string; new?: string; asset?: string }>;
}) {
const session = await requireSession();
requirePermission(session, "risk:read");
@@ -95,6 +100,22 @@ export default async function RisksPage({
orderBy: { name: "asc" },
})
: [];
const availableMeasures =
canWrite && params.edit && modalRisk
? await db.measure.findMany({
where: { id: { notIn: modalRisk.riskMeasures.map((rm) => rm.measureId) } },
select: { id: true, refNo: true, title: true },
orderBy: { refNo: "asc" },
})
: [];
// Aus dem Asset-Popup heraus angelegt: ?new=1&asset=<id>
const preselectedAsset =
canWrite && params.new && params.asset
? await db.asset.findUnique({
where: { id: params.asset },
select: { id: true, name: true },
})
: null;
// Heatmap-Zellen: Auswirkung (Y, 5 oben) × Wahrscheinlichkeit (X)
const cell = (likelihood: number, impact: number) =>
@@ -128,11 +149,11 @@ export default async function RisksPage({
}
/>
<div className="grid gap-4 xl:grid-cols-2">
<div className="grid gap-4">
{/* Heatmap */}
<div className="shadow-card rounded-xl border bg-card p-4">
<SectTitle title={t("heatmap")} sub={t("heatmapSub")} />
<div className="mt-3 grid grid-cols-[1.4rem_repeat(5,1fr)] gap-1">
<div className="mx-auto mt-3 grid w-full max-w-3xl grid-cols-[1.4rem_repeat(5,1fr)] gap-1">
{[5, 4, 3, 2, 1].map((impact) => (
<div key={impact} className="contents">
<div className="grid place-items-center text-[11px] font-bold text-muted-foreground">
@@ -241,11 +262,12 @@ export default async function RisksPage({
users={users}
processes={processes}
availableAssets={availableAssets}
availableMeasures={availableMeasures}
/>
) : modalRisk ? (
<RiskDetailModal risk={modalRisk} canWrite={canWrite} />
) : params.new && canWrite ? (
<RiskCreateModal users={users} processes={processes} />
<RiskCreateModal users={users} processes={processes} preselectedAsset={preselectedAsset} />
) : null}
</main>
);
+15 -2
View File
@@ -1,6 +1,6 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Pencil, Trash2, X } from "lucide-react";
import { Pencil, Plus, Trash2, X } from "lucide-react";
import type { Prisma } from "@prisma/client";
import {
addAssetRelation,
@@ -55,6 +55,7 @@ export async function AssetDetailModal({
const tStatus = await getTranslations("assetStatus");
const tRole = await getTranslations("processRole");
const tLevel = await getTranslations("riskLevel");
const tRisks = await getTranslations("risks");
const tc = await getTranslations("common");
const tp = await getTranslations("processes");
@@ -184,7 +185,19 @@ export async function AssetDetailModal({
{/* Zugeordnete Risiken — Band wie die BIA-Kennzahlen im Prozess-Popup */}
<div className="mx-5 mb-5 rounded-xl border border-[#e6e2f3] bg-[#f2f0f9] p-4 text-[12.5px] text-[#5a4e86]">
<b>{t("linkedRisks")}</b>
<div className="flex items-center justify-between gap-2">
<b>{t("linkedRisks")}</b>
{canWrite && (
<Button
variant="outline"
size="sm"
nativeButton={false}
render={<Link href={`/risks?new=1&asset=${asset.id}`} />}
>
<Plus className="size-3.5" /> {tRisks("createFromAsset")}
</Button>
)}
</div>
{asset.riskAssets.length === 0 && <p className="mt-1">{t("linkedRisksPlaceholder")}</p>}
<ul className="mt-2 space-y-1.5">
{asset.riskAssets.map((ra) => (
+166
View File
@@ -0,0 +1,166 @@
"use client";
import { useTransition } from "react";
import { useRouter } from "next/navigation";
import {
DndContext,
PointerSensor,
useDraggable,
useDroppable,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import { cn } from "@/lib/utils";
export type KanbanCard = {
id: string;
refLabel: string;
title: string;
dueLabel: string | null;
overdue: boolean;
priorityLabel: string;
priorityClass: string;
ownerInitials: string | null;
riskCount: number;
};
export type KanbanColumn = {
status: string;
label: string;
cards: KanbanCard[];
};
/** Maßnahmen-Kanban (SPEC §4.4): Drag-and-Drop verschiebt den Status. */
export function KanbanBoard({
columns,
canWrite,
onMove,
}: {
columns: KanbanColumn[];
canWrite: boolean;
// Server-Action: (measureId, newStatus)
onMove: (measureId: string, status: string) => Promise<void>;
}) {
const router = useRouter();
const [, startTransition] = useTransition();
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } })
);
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over) return;
const fromStatus = active.data.current?.status as string;
const toStatus = String(over.id);
if (fromStatus === toStatus) return;
startTransition(() => onMove(String(active.id), toStatus));
}
return (
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
<div className="grid gap-3 md:grid-cols-3">
{columns.map((col) => (
<Column key={col.status} column={col} canWrite={canWrite} onOpen={(id) => router.push(`/measures?detail=${id}`)} />
))}
</div>
</DndContext>
);
}
function Column({
column,
canWrite,
onOpen,
}: {
column: KanbanColumn;
canWrite: boolean;
onOpen: (id: string) => void;
}) {
const { setNodeRef, isOver } = useDroppable({ id: column.status });
return (
<div
ref={setNodeRef}
className={cn(
"rounded-xl border bg-muted/60 p-2.5 transition-colors",
isOver && "border-[#5d52a3] bg-[#f2f0f9]"
)}
>
<div className="mb-2 flex items-center justify-between px-1">
<span className="font-heading text-[12.5px] font-semibold">{column.label}</span>
<span className="rounded-full bg-white px-2 py-0.5 text-[11px] font-bold text-muted-foreground">
{column.cards.length}
</span>
</div>
<div className="space-y-2">
{column.cards.map((card) => (
<Card key={card.id} card={card} status={column.status} canWrite={canWrite} onOpen={onOpen} />
))}
</div>
</div>
);
}
function Card({
card,
status,
canWrite,
onOpen,
}: {
card: KanbanCard;
status: string;
canWrite: boolean;
onOpen: (id: string) => void;
}) {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
id: card.id,
data: { status },
disabled: !canWrite,
});
return (
<div
ref={setNodeRef}
{...listeners}
{...attributes}
onClick={() => !isDragging && onOpen(card.id)}
style={
transform
? { transform: `translate(${transform.x}px, ${transform.y}px)` }
: undefined
}
className={cn(
"shadow-card cursor-pointer rounded-lg border bg-card p-3 text-sm",
canWrite && "cursor-grab active:cursor-grabbing",
isDragging && "z-50 opacity-80 shadow-lg"
)}
>
<div className="flex items-center justify-between gap-2">
<span className="text-[11px] font-bold text-muted-foreground">{card.refLabel}</span>
<span
className={cn(
"rounded-full px-2 py-[2px] text-[10.5px] font-bold",
card.priorityClass
)}
>
{card.priorityLabel}
</span>
</div>
<p className="mt-1 font-semibold leading-snug">{card.title}</p>
<div className="mt-2 flex items-center justify-between text-[11.5px] text-muted-foreground">
<span className={cn(card.overdue && "font-bold text-[#d64c4c]")}>
{card.dueLabel ?? "—"}
</span>
<span className="flex items-center gap-1.5">
{card.riskCount > 0 && <span title="Verknüpfte Risiken"> {card.riskCount}</span>}
{card.ownerInitials && (
<span className="grid size-6 place-items-center rounded-full bg-[#e6e2f3] text-[10px] font-bold text-[#5d52a3]">
{card.ownerInitials}
</span>
)}
</span>
</div>
</div>
);
}
+274
View File
@@ -0,0 +1,274 @@
import Link from "next/link";
import { getFormatter, getTranslations } from "next-intl/server";
import { Pencil, Trash2 } from "lucide-react";
import type { Prisma } from "@prisma/client";
import { createMeasure, deleteMeasure, updateMeasure } 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 { Pill } from "@/components/mockup-ui";
import { riskLevel, riskRef, RISK_PILL_TONE } from "@/lib/risk";
import { measureRef, MEASURE_PRIORITY_TONE, MEASURE_STATUS_TONE } from "@/lib/measure";
export type MeasureWithDetail = Prisma.MeasureGetPayload<{
include: {
owner: { select: { id: true; name: true } };
riskMeasures: {
include: { risk: { select: { id: true; refNo: true; title: true; score: true } } };
};
};
}>;
const STATUSES = ["OPEN", "IN_PROGRESS", "DONE"] as const;
const PRIORITIES = ["LOW", "MEDIUM", "HIGH"] as const;
/** Read-only-Maßnahmen-Detail als Popup. */
export async function MeasureDetailModal({
measure,
canWrite,
}: {
measure: MeasureWithDetail;
canWrite: boolean;
}) {
const t = await getTranslations("measures");
const tr = await getTranslations("risks");
const tStatus = await getTranslations("measureStatus");
const tPrio = await getTranslations("measurePriority");
const tLevel = await getTranslations("riskLevel");
const tc = await getTranslations("common");
const format = await getFormatter();
return (
<Modal
title={t("detailHeading", { ref: measureRef(measure.refNo) })}
sub={t("detailSub")}
headerExtra={
<span className="flex items-center gap-2">
<Pill tone={MEASURE_PRIORITY_TONE[measure.priority]}>{tPrio(measure.priority)}</Pill>
<Pill tone={MEASURE_STATUS_TONE[measure.status]}>{tStatus(measure.status)}</Pill>
</span>
}
closeHref="/measures"
closeLabel={t("close")}
footer={
<>
{canWrite && (
<Button
variant="outline"
nativeButton={false}
render={<Link href={`/measures?edit=${measure.id}`} />}
>
<Pencil className="size-4" /> {tc("edit")}
</Button>
)}
<Button nativeButton={false} render={<Link href="/measures" />}>
{t("close")}
</Button>
</>
}
>
<div className="grid gap-5 p-5 md:grid-cols-2">
<div className="rounded-xl border border-l-[3px] border-l-[#5d52a3] bg-[#faf9fd] p-4">
<b>{measure.title}</b>
{measure.description && (
<p className="mt-1.5 text-[12.5px] leading-relaxed">{measure.description}</p>
)}
<dl className="mt-3 grid grid-cols-[7.5rem_1fr] gap-1.5 text-[12.5px]">
<dt className="text-muted-foreground">{t("owner")}</dt>
<dd>{measure.owner?.name ?? tc("none")}</dd>
<dt className="text-muted-foreground">{t("dueDate")}</dt>
<dd>
{measure.dueDate
? format.dateTime(measure.dueDate, { dateStyle: "medium" })
: tc("none")}
</dd>
</dl>
</div>
<div>
<div className="mb-2.5 flex items-center gap-2.5">
<Pill tone="info">{t("linkedRisks")}</Pill>
<span className="text-[12.5px] text-muted-foreground">{t("linkedRisksNote")}</span>
</div>
{measure.riskMeasures.length === 0 && (
<p className="text-sm text-muted-foreground">{t("noRisks")}</p>
)}
<ul className="space-y-1.5 text-sm">
{measure.riskMeasures.map((rm) => (
<li key={rm.id} className="flex flex-wrap items-center gap-2">
<Link href={`/risks?detail=${rm.risk.id}`} className="font-bold hover:underline">
{riskRef(rm.risk.refNo)}
</Link>
<Link href={`/risks?detail=${rm.risk.id}`} className="hover:underline">
{rm.risk.title}
</Link>
<Pill tone={RISK_PILL_TONE[riskLevel(rm.risk.score)]}>
{rm.risk.score} · {tLevel(riskLevel(rm.risk.score))}
</Pill>
<span className="text-xs text-muted-foreground">
{tr("reduction")}: {rm.reductionLikelihood} / {rm.reductionImpact}
</span>
</li>
))}
</ul>
</div>
</div>
</Modal>
);
}
/** Formularfelder für Anlegen/Bearbeiten. */
async function MeasureFields({
measure,
users,
}: {
measure?: MeasureWithDetail;
users: { id: string; name: string }[];
}) {
const t = await getTranslations("measures");
const tStatus = await getTranslations("measureStatus");
const tPrio = await getTranslations("measurePriority");
const tc = await getTranslations("common");
const selectClass = "h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm";
return (
<>
<div>
<Label htmlFor="title">{t("titleField")}</Label>
<Input id="title" name="title" required defaultValue={measure?.title} className="mt-1" />
</div>
<div>
<Label htmlFor="description">{t("description")}</Label>
<Textarea
id="description"
name="description"
rows={3}
defaultValue={measure?.description ?? ""}
className="mt-1"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="status">{t("status")}</Label>
<select id="status" name="status" defaultValue={measure?.status ?? "OPEN"} className={`${selectClass} mt-1`}>
{STATUSES.map((v) => (
<option key={v} value={v}>
{tStatus(v)}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="priority">{t("priority")}</Label>
<select
id="priority"
name="priority"
defaultValue={measure?.priority ?? "MEDIUM"}
className={`${selectClass} mt-1`}
>
{PRIORITIES.map((v) => (
<option key={v} value={v}>
{tPrio(v)}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="ownerId">{t("owner")}</Label>
<select id="ownerId" name="ownerId" defaultValue={measure?.ownerId ?? ""} className={`${selectClass} mt-1`}>
<option value="">{tc("none")}</option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="dueDate">{t("dueDate")}</Label>
<Input
id="dueDate"
name="dueDate"
type="date"
defaultValue={measure?.dueDate ? measure.dueDate.toISOString().slice(0, 10) : ""}
className="mt-1"
/>
</div>
</div>
</>
);
}
/** Bearbeiten als Popup. */
export async function MeasureEditModal({
measure,
users,
}: {
measure: MeasureWithDetail;
users: { id: string; name: string }[];
}) {
const t = await getTranslations("measures");
const tc = await getTranslations("common");
return (
<Modal
title={t("editTitle")}
sub={`${measureRef(measure.refNo)} · ${measure.title}`}
closeHref={`/measures?detail=${measure.id}`}
closeLabel={t("close")}
>
<div className="p-5">
<form
key={measure.updatedAt.toISOString()}
action={updateMeasure.bind(null, measure.id)}
className="space-y-4"
>
<MeasureFields measure={measure} users={users} />
<div className="flex gap-2">
<Button type="submit">{tc("save")}</Button>
<Button
variant="outline"
nativeButton={false}
render={<Link href={`/measures?detail=${measure.id}`} />}
>
{tc("cancel")}
</Button>
</div>
</form>
<div className="mt-5 border-t pt-4">
<form action={deleteMeasure.bind(null, measure.id)}>
<Button type="submit" variant="destructive" size="sm">
<Trash2 className="size-4" /> {tc("delete")}
</Button>
</form>
</div>
</div>
</Modal>
);
}
/** Anlegen als Popup. */
export async function MeasureCreateModal({
users,
}: {
users: { id: string; name: string }[];
}) {
const t = await getTranslations("measures");
const tc = await getTranslations("common");
return (
<Modal title={t("createTitle")} closeHref="/measures" closeLabel={t("close")}>
<form action={createMeasure} className="space-y-4 p-5">
<MeasureFields users={[...users]} />
<div className="flex gap-2 pt-1">
<Button type="submit">{tc("save")}</Button>
<Button variant="outline" nativeButton={false} render={<Link href="/measures" />}>
{tc("cancel")}
</Button>
</div>
</form>
</Modal>
);
}
+214 -76
View File
@@ -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>
+15
View File
@@ -0,0 +1,15 @@
export function measureRef(refNo: number): string {
return `M-${String(refNo).padStart(3, "0")}`;
}
export const MEASURE_STATUS_TONE = {
OPEN: "mut",
IN_PROGRESS: "info",
DONE: "ok",
} as const;
export const MEASURE_PRIORITY_TONE = {
LOW: "mut",
MEDIUM: "info",
HIGH: "risk",
} as const;
+261
View File
@@ -0,0 +1,261 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { requireSession } from "@/server/auth";
import { dbForTenant, prisma } from "@/server/db";
import { requirePermission } from "@/server/rbac";
import { writeAuditLog } from "@/server/audit";
import { recomputeResidualRisk } from "@/server/risk-calc";
const measureSchema = z.object({
title: z.string().trim().min(1).max(200),
description: z.string().trim().max(5000).optional(),
status: z.enum(["OPEN", "IN_PROGRESS", "DONE"]),
priority: z.enum(["LOW", "MEDIUM", "HIGH"]),
ownerId: z.string().optional(),
dueDate: z.string().optional(),
});
const reduction = z.coerce.number().int().min(0).max(4);
function parseMeasureForm(formData: FormData) {
const parsed = measureSchema.parse({
title: formData.get("title"),
description: formData.get("description") || undefined,
status: formData.get("status") ?? "OPEN",
priority: formData.get("priority") ?? "MEDIUM",
ownerId: formData.get("ownerId") || undefined,
dueDate: formData.get("dueDate") || undefined,
});
return {
title: parsed.title,
description: parsed.description || null,
status: parsed.status,
priority: parsed.priority,
ownerId: parsed.ownerId || null,
dueDate: parsed.dueDate ? new Date(parsed.dueDate) : null,
};
}
async function nextRefNo(tenantId: string) {
const last = await prisma.measure.aggregate({
where: { tenantId },
_max: { refNo: true },
});
return (last._max.refNo ?? 0) + 1;
}
export async function createMeasure(formData: FormData) {
const session = await requireSession();
requirePermission(session, "measure:write");
const db = dbForTenant(session.user.tenantId);
const data = parseMeasureForm(formData);
const measure = await db.measure.create({
data: {
...data,
refNo: await nextRefNo(session.user.tenantId),
tenantId: session.user.tenantId,
createdBy: session.user.id,
},
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "create",
entity: "measure",
entityId: measure.id,
after: data,
});
revalidatePath("/measures");
redirect(`/measures?detail=${measure.id}`);
}
export async function updateMeasure(measureId: string, formData: FormData) {
const session = await requireSession();
requirePermission(session, "measure:write");
const db = dbForTenant(session.user.tenantId);
const before = await db.measure.findUnique({ where: { id: measureId } });
if (!before) throw new Error("Maßnahme nicht gefunden");
const data = parseMeasureForm(formData);
await db.measure.update({ where: { id: measureId }, data });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "measure",
entityId: measureId,
before,
after: data,
});
revalidatePath("/measures");
revalidatePath("/risks");
redirect(`/measures?detail=${measureId}`);
}
/** Status-Wechsel per Drag-and-Drop auf dem Kanban-Board. */
export async function updateMeasureStatus(measureId: string, status: string) {
const session = await requireSession();
requirePermission(session, "measure:write");
const db = dbForTenant(session.user.tenantId);
const parsed = z.enum(["OPEN", "IN_PROGRESS", "DONE"]).parse(status);
const before = await db.measure.findUnique({ where: { id: measureId } });
if (!before || before.status === parsed) return;
await db.measure.update({ where: { id: measureId }, data: { status: parsed } });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "measure",
entityId: measureId,
before: { status: before.status },
after: { status: parsed },
});
revalidatePath("/measures");
revalidatePath("/risks");
}
export async function deleteMeasure(measureId: string) {
const session = await requireSession();
requirePermission(session, "measure:write");
const db = dbForTenant(session.user.tenantId);
const before = await db.measure.findUnique({
where: { id: measureId },
include: { riskMeasures: { select: { riskId: true } } },
});
if (!before) throw new Error("Maßnahme nicht gefunden");
await db.measure.delete({ where: { id: measureId } });
// Rest-Risiken der betroffenen Risiken neu berechnen (Links kaskadiert gelöscht)
for (const rm of before.riskMeasures) {
await recomputeResidualRisk(db, rm.riskId);
}
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "delete",
entity: "measure",
entityId: measureId,
before,
});
revalidatePath("/measures");
revalidatePath("/risks");
redirect("/measures");
}
/** Bestehende Maßnahme mit einem Risiko verknüpfen (inkl. Minderung). */
export async function linkMeasureToRisk(riskId: string, formData: FormData) {
const session = await requireSession();
requirePermission(session, "risk:write");
const db = dbForTenant(session.user.tenantId);
const measureId = z.string().min(1).parse(formData.get("measureId"));
const reductionLikelihood = reduction.parse(formData.get("reductionLikelihood") ?? 0);
const reductionImpact = reduction.parse(formData.get("reductionImpact") ?? 0);
const riskCount = await db.risk.count({ where: { id: riskId } });
const measureCount = await db.measure.count({ where: { id: measureId } });
if (riskCount !== 1 || measureCount !== 1) throw new Error("Nicht gefunden");
await db.riskMeasure.upsert({
where: { riskId_measureId: { riskId, measureId } },
update: { reductionLikelihood, reductionImpact },
create: {
riskId,
measureId,
reductionLikelihood,
reductionImpact,
tenantId: session.user.tenantId,
},
});
await recomputeResidualRisk(db, riskId);
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "risk_measure",
entityId: riskId,
after: { riskId, measureId, reductionLikelihood, reductionImpact },
});
revalidatePath("/risks");
revalidatePath("/measures");
}
/** Neue Maßnahme direkt aus dem Risiko heraus anlegen und verknüpfen (SPEC §4.2). */
export async function createMeasureForRisk(riskId: string, formData: FormData) {
const session = await requireSession();
requirePermission(session, "risk:write");
requirePermission(session, "measure:write");
const db = dbForTenant(session.user.tenantId);
const riskCount = await db.risk.count({ where: { id: riskId } });
if (riskCount !== 1) throw new Error("Risiko nicht gefunden");
const title = z.string().trim().min(1).max(200).parse(formData.get("title"));
const reductionLikelihood = reduction.parse(formData.get("reductionLikelihood") ?? 0);
const reductionImpact = reduction.parse(formData.get("reductionImpact") ?? 0);
const measure = await db.measure.create({
data: {
title,
refNo: await nextRefNo(session.user.tenantId),
tenantId: session.user.tenantId,
createdBy: session.user.id,
},
});
await db.riskMeasure.create({
data: {
riskId,
measureId: measure.id,
reductionLikelihood,
reductionImpact,
tenantId: session.user.tenantId,
},
});
await recomputeResidualRisk(db, riskId);
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "create",
entity: "measure",
entityId: measure.id,
after: { title, linkedRisk: riskId, reductionLikelihood, reductionImpact },
});
revalidatePath("/risks");
revalidatePath("/measures");
}
export async function unlinkMeasureFromRisk(riskId: string, riskMeasureId: string) {
const session = await requireSession();
requirePermission(session, "risk:write");
const db = dbForTenant(session.user.tenantId);
const before = await db.riskMeasure.findUnique({ where: { id: riskMeasureId } });
if (!before) return;
await db.riskMeasure.delete({ where: { id: riskMeasureId } });
await recomputeResidualRisk(db, riskId);
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "delete",
entity: "risk_measure",
entityId: riskMeasureId,
before,
});
revalidatePath("/risks");
revalidatePath("/measures");
}
+17 -14
View File
@@ -7,11 +7,9 @@ import { requireSession } from "@/server/auth";
import { dbForTenant, prisma } from "@/server/db";
import { requirePermission } from "@/server/rbac";
import { writeAuditLog } from "@/server/audit";
import { recomputeResidualRisk } from "@/server/risk-calc";
const scale = z.coerce.number().int().min(1).max(5);
const optionalScale = z
.union([z.literal(""), z.literal(null), scale])
.transform((v) => (v === "" || v === null ? null : v));
const riskSchema = z.object({
title: z.string().trim().min(1).max(200),
@@ -20,8 +18,6 @@ const riskSchema = z.object({
vulnerability: z.string().trim().max(500).optional(),
likelihood: scale,
impact: scale,
residualLikelihood: optionalScale,
residualImpact: optionalScale,
treatment: z.enum(["AVOID", "MITIGATE", "TRANSFER", "ACCEPT"]),
status: z.enum(["OPEN", "IN_TREATMENT", "ACCEPTED", "CLOSED"]),
ownerId: z.string().optional(),
@@ -36,17 +32,11 @@ function parseRiskForm(formData: FormData) {
vulnerability: formData.get("vulnerability") || undefined,
likelihood: formData.get("likelihood"),
impact: formData.get("impact"),
residualLikelihood: formData.get("residualLikelihood") ?? "",
residualImpact: formData.get("residualImpact") ?? "",
treatment: formData.get("treatment"),
status: formData.get("status"),
ownerId: formData.get("ownerId") || undefined,
processId: formData.get("processId") || undefined,
});
const residual =
parsed.residualLikelihood != null && parsed.residualImpact != null
? parsed.residualLikelihood * parsed.residualImpact
: null;
return {
title: parsed.title,
description: parsed.description || null,
@@ -55,9 +45,6 @@ function parseRiskForm(formData: FormData) {
likelihood: parsed.likelihood,
impact: parsed.impact,
score: parsed.likelihood * parsed.impact,
residualLikelihood: parsed.residualLikelihood,
residualImpact: parsed.residualImpact,
residualScore: residual,
treatment: parsed.treatment,
status: parsed.status,
ownerId: parsed.ownerId || null,
@@ -85,6 +72,17 @@ export async function createRisk(formData: FormData) {
},
});
// Optional: direkt aus einem Asset heraus angelegt → Asset sofort verknüpfen
const assetId = formData.get("assetId") as string | null;
if (assetId) {
const assetCount = await db.asset.count({ where: { id: assetId } });
if (assetCount === 1) {
await db.riskAsset.create({
data: { riskId: risk.id, assetId, tenantId: session.user.tenantId },
});
}
}
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
@@ -94,6 +92,7 @@ export async function createRisk(formData: FormData) {
after: data,
});
revalidatePath("/risks");
revalidatePath("/assets");
redirect(`/risks?edit=${risk.id}`);
}
@@ -107,6 +106,8 @@ export async function updateRisk(riskId: string, formData: FormData) {
const data = parseRiskForm(formData);
await db.risk.update({ where: { id: riskId }, data });
// Brutto-Bewertung geändert → Rest-Risiko aus den Maßnahmen neu ableiten
await recomputeResidualRisk(db, riskId);
await writeAuditLog({
tenantId: session.user.tenantId,
@@ -168,6 +169,7 @@ export async function addRiskAsset(riskId: string, formData: FormData) {
after: { riskId, assetId },
});
revalidatePath("/risks");
revalidatePath("/assets");
}
export async function removeRiskAsset(riskId: string, riskAssetId: string) {
@@ -188,4 +190,5 @@ export async function removeRiskAsset(riskId: string, riskAssetId: string) {
before,
});
revalidatePath("/risks");
revalidatePath("/assets");
}
+2
View File
@@ -35,6 +35,8 @@ const TENANT_MODELS = new Set<string>([
"BiaEntry",
"Risk",
"RiskAsset",
"Measure",
"RiskMeasure",
]);
/**
+38
View File
@@ -0,0 +1,38 @@
import type { TenantDb } from "./db";
/**
* Rest-Risiko-Berechnung (SPEC §4.2): Das Rest-Risiko wird NICHT manuell
* gepflegt, sondern ergibt sich aus den verknüpften Maßnahmen. Jede
* Verknüpfung trägt eine erwartete Minderung (Stufen 04) je Dimension;
* die Minderungen werden summiert und von der Brutto-Bewertung abgezogen
* (Untergrenze 1). Ohne Maßnahmen gibt es kein Rest-Risiko (null).
*/
export async function recomputeResidualRisk(db: TenantDb, riskId: string) {
const risk = await db.risk.findUnique({
where: { id: riskId },
include: { riskMeasures: { select: { reductionLikelihood: true, reductionImpact: true } } },
});
if (!risk) return;
if (risk.riskMeasures.length === 0) {
await db.risk.update({
where: { id: riskId },
data: { residualLikelihood: null, residualImpact: null, residualScore: null },
});
return;
}
const reductionL = risk.riskMeasures.reduce((s, m) => s + m.reductionLikelihood, 0);
const reductionI = risk.riskMeasures.reduce((s, m) => s + m.reductionImpact, 0);
const residualLikelihood = Math.max(1, risk.likelihood - reductionL);
const residualImpact = Math.max(1, risk.impact - reductionI);
await db.risk.update({
where: { id: riskId },
data: {
residualLikelihood,
residualImpact,
residualScore: residualLikelihood * residualImpact,
},
});
}