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>
|
||||
);
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ const TENANT_MODELS = new Set<string>([
|
||||
"BiaEntry",
|
||||
"Risk",
|
||||
"RiskAsset",
|
||||
"Measure",
|
||||
"RiskMeasure",
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 0–4) 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user