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:
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user