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
+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>
);