Iteration 3: Risikoanalyse + Menü-Trennung + Prozesskategorie
- Risiko-Modul (SPEC §4.2): Risk/RiskAsset-Schema mit RLS, laufende Nummer je Mandant (R-001), Brutto-/Rest-Bewertung (5×5), Behandlung, Status, Owner- und Prozess-Bezug, n:m betroffene Assets - /risks im Mockup-Layout: 5×5-Heatmap (Farb-Bänder + R-Chips, Legende, Achsen) und Risikoregister mit Score-Pillen, Behandlungs-Tags, Status - Risiko-Popups nach App-Muster: Read-only-Detail (Bewertungs-Band Brutto vs. Rest, betroffene Assets, Maßnahmen-Platzhalter für Iteration 4), Bearbeiten im Popup (Bewertung, Assets verknüpfen, Löschen), Anlegen; Server-Actions mit Zod, RBAC und Audit-Log - Menü aufgetrennt: eigene Punkte "Asset-Inventar", "Business Impact Analyse" und "Risikoanalyse" (Modul-Tabs entfernt) - Prozesskategorie (Kernprozess/Managementprozess/Unterstützender Prozess): Schema-Feld, Formulare, Listen-Spalte, Popup-Tag - Asset-Detail-Popup optisch ans Prozess-Popup angeglichen (Stammdaten- Karte mit violettem Rand, Abhängigkeiten-Tabelle, Risiken-Band) - "Zugeordnete Risiken" jetzt echt: im Asset-Popup (verknüpfte Risiken) und im Prozess-Popup (direkt + über Assets aggregiert); Dashboard-KPI "Offene Risiken" mit echten Zahlen; Seed mit 4 Beispiel-Risiken Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,6 @@ import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission, requirePermission } from "@/server/rbac";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ModuleTabs } from "@/components/module-tabs";
|
||||
import { FilterTabs } from "@/components/filter-tabs";
|
||||
import {
|
||||
CiaBadge,
|
||||
@@ -89,9 +88,26 @@ export default async function AssetsPage({
|
||||
where: { id: modalId },
|
||||
include: {
|
||||
owner: { select: { name: true } },
|
||||
relationsFrom: { include: { relatedAsset: { select: { id: true, name: true } } } },
|
||||
relationsFrom: {
|
||||
include: {
|
||||
relatedAsset: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
confidentiality: true,
|
||||
integrity: true,
|
||||
availability: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
relationsTo: { include: { asset: { select: { id: true, name: true } } } },
|
||||
processAssets: { include: { process: { select: { id: true, name: true } } } },
|
||||
riskAssets: {
|
||||
include: { risk: { select: { id: true, refNo: true, title: true, score: true } } },
|
||||
orderBy: { risk: { score: "desc" } },
|
||||
},
|
||||
},
|
||||
})
|
||||
: null;
|
||||
@@ -138,13 +154,6 @@ export default async function AssetsPage({
|
||||
}
|
||||
/>
|
||||
|
||||
<ModuleTabs
|
||||
active="/assets"
|
||||
tabs={[
|
||||
{ href: "/assets", label: t("tabAssets") },
|
||||
{ href: "/processes", label: t("tabProcesses") },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<KpiCard label={t("kpiTotal")} value={total} trend={t("kpiTotalTrend", { count: typeCount })} />
|
||||
|
||||
@@ -6,14 +6,14 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
||||
export default async function DashboardPage() {
|
||||
const session = await requireSession();
|
||||
const t = await getTranslations("dashboard");
|
||||
const tc = await getTranslations("common");
|
||||
const format = await getFormatter();
|
||||
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const [assetCount, processCount, criticalCount, activities] = await Promise.all([
|
||||
const [assetCount, processCount, criticalCount, openRisks, activities] = await Promise.all([
|
||||
db.asset.count(),
|
||||
db.process.count(),
|
||||
db.biaEntry.count({ where: { criticality: { gte: 3 } } }),
|
||||
db.risk.count({ where: { status: { in: ["OPEN", "IN_TREATMENT"] } } }),
|
||||
db.auditLog.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 6,
|
||||
@@ -34,7 +34,7 @@ export default async function DashboardPage() {
|
||||
{ label: t("kpiAssets"), value: String(assetCount), hint: null },
|
||||
{ label: t("kpiProcesses"), value: String(processCount), hint: null },
|
||||
{ label: t("kpiCritical"), value: String(criticalCount), hint: t("kpiCriticalHint") },
|
||||
{ label: t("kpiRisks"), value: tc("none"), hint: t("kpiRisksHint") },
|
||||
{ label: t("kpiRisks"), value: String(openRisks), hint: null },
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getTranslations } from "next-intl/server";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Boxes,
|
||||
GitBranch,
|
||||
ShieldAlert,
|
||||
ClipboardCheck,
|
||||
KanbanSquare,
|
||||
@@ -32,8 +33,9 @@ export default async function AppLayout({
|
||||
|
||||
const nav = [
|
||||
{ href: "/dashboard", label: t("dashboard"), icon: LayoutDashboard, enabled: true },
|
||||
{ href: "/assets", label: t("assetsBia"), icon: Boxes, enabled: true, match: ["/assets", "/processes"] },
|
||||
{ href: "/risks", label: t("risks"), icon: ShieldAlert, enabled: false },
|
||||
{ href: "/assets", label: t("assets"), icon: Boxes, enabled: true },
|
||||
{ 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: "/incidents", label: t("incidents"), icon: Siren, enabled: false },
|
||||
@@ -74,7 +76,7 @@ export default async function AppLayout({
|
||||
<nav className="flex-1 space-y-0.5 overflow-y-auto px-2.5 py-3">
|
||||
{nav.map((item) =>
|
||||
item.enabled ? (
|
||||
<NavLink key={item.href} href={item.href} match={item.match}>
|
||||
<NavLink key={item.href} href={item.href}>
|
||||
<item.icon className="size-[18px] opacity-85" />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
|
||||
@@ -5,8 +5,7 @@ import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission, requirePermission } from "@/server/rbac";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ModuleTabs } from "@/components/module-tabs";
|
||||
import { CriticalityPill, PageHead, SectTitle } from "@/components/mockup-ui";
|
||||
import { CriticalityPill, PageHead, SectTitle, Tag } from "@/components/mockup-ui";
|
||||
import {
|
||||
ProcessCreateModal,
|
||||
ProcessDetailModal,
|
||||
@@ -51,6 +50,7 @@ export default async function ProcessesPage({
|
||||
const t = await getTranslations("processes");
|
||||
const tAssets = await getTranslations("assets");
|
||||
const tCrit = await getTranslations("criticality");
|
||||
const tCat = await getTranslations("processCategory");
|
||||
const tc = await getTranslations("common");
|
||||
|
||||
const params = await searchParams;
|
||||
@@ -80,6 +80,20 @@ export default async function ProcessesPage({
|
||||
orderBy: { name: "asc" },
|
||||
})
|
||||
: [];
|
||||
// Zugeordnete Risiken: direkt am Prozess ODER an einem seiner Assets
|
||||
const modalRisks = modalProcess
|
||||
? await db.risk.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ processId: modalProcess.id },
|
||||
{ riskAssets: { some: { assetId: { in: modalProcess.processAssets.map((pa) => pa.assetId) } } } },
|
||||
],
|
||||
},
|
||||
select: { id: true, refNo: true, title: true, score: true },
|
||||
orderBy: { score: "desc" },
|
||||
})
|
||||
: [];
|
||||
|
||||
const availableAssets =
|
||||
canWrite && params.edit && modalProcess
|
||||
? await db.asset.findMany({
|
||||
@@ -111,13 +125,6 @@ export default async function ProcessesPage({
|
||||
}
|
||||
/>
|
||||
|
||||
<ModuleTabs
|
||||
active="/processes"
|
||||
tabs={[
|
||||
{ href: "/assets", label: tAssets("tabAssets") },
|
||||
{ href: "/processes", label: tAssets("tabProcesses") },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="shadow-card mt-4 rounded-xl border bg-card">
|
||||
<div className="p-4 pb-0">
|
||||
@@ -127,6 +134,7 @@ export default async function ProcessesPage({
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("process")}</TableHead>
|
||||
<TableHead>{t("category")}</TableHead>
|
||||
<TableHead>{t("owner")}</TableHead>
|
||||
<TableHead>RTO</TableHead>
|
||||
<TableHead>RPO</TableHead>
|
||||
@@ -137,7 +145,7 @@ export default async function ProcessesPage({
|
||||
<TableBody>
|
||||
{processes.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">
|
||||
<TableCell colSpan={7} className="py-8 text-center text-muted-foreground">
|
||||
{t("empty")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -149,6 +157,9 @@ export default async function ProcessesPage({
|
||||
{process.name}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Tag>{tCat(process.category)}</Tag>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{process.owner?.name ?? tc("none")}
|
||||
</TableCell>
|
||||
@@ -174,7 +185,7 @@ export default async function ProcessesPage({
|
||||
{modalProcess && params.edit && canWrite ? (
|
||||
<ProcessEditModal process={modalProcess} users={users} availableAssets={availableAssets} />
|
||||
) : modalProcess ? (
|
||||
<ProcessDetailModal process={modalProcess} canWrite={canWrite} />
|
||||
<ProcessDetailModal process={modalProcess} risks={modalRisks} canWrite={canWrite} />
|
||||
) : params.new && canWrite ? (
|
||||
<ProcessCreateModal users={users} />
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import Link from "next/link";
|
||||
import { 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 { Button } from "@/components/ui/button";
|
||||
import { PageHead, Pill, SectTitle, Tag } from "@/components/mockup-ui";
|
||||
import {
|
||||
RiskCreateModal,
|
||||
RiskDetailModal,
|
||||
RiskEditModal,
|
||||
} from "@/components/risk-modals";
|
||||
import { riskLevel, riskRef, RISK_CELL_COLORS, RISK_PILL_TONE } from "@/lib/risk";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
const RISK_INCLUDE = {
|
||||
owner: { select: { id: true, name: true } },
|
||||
process: { select: { id: true, name: true } },
|
||||
riskAssets: {
|
||||
include: {
|
||||
asset: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
confidentiality: true,
|
||||
integrity: true,
|
||||
availability: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const STATUS_TONE = {
|
||||
OPEN: "risk",
|
||||
IN_TREATMENT: "warn",
|
||||
ACCEPTED: "info",
|
||||
CLOSED: "mut",
|
||||
} as const;
|
||||
|
||||
export default async function RisksPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ detail?: string; edit?: string; new?: string }>;
|
||||
}) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "risk:read");
|
||||
const t = await getTranslations("risks");
|
||||
const tAssets = await getTranslations("assets");
|
||||
const tTreat = await getTranslations("riskTreatment");
|
||||
const tStatus = await getTranslations("riskStatus");
|
||||
const tLevel = await getTranslations("riskLevel");
|
||||
const tc = await getTranslations("common");
|
||||
|
||||
const params = await searchParams;
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const canWrite = hasPermission(session, "risk:write");
|
||||
|
||||
const risks = await db.risk.findMany({
|
||||
include: { owner: { select: { name: true } } },
|
||||
orderBy: { score: "desc" },
|
||||
take: 200,
|
||||
});
|
||||
|
||||
const modalId = params.edit && canWrite ? params.edit : params.detail;
|
||||
const modalRisk = modalId
|
||||
? await db.risk.findUnique({ where: { id: modalId }, include: RISK_INCLUDE })
|
||||
: null;
|
||||
|
||||
const needsFormData = canWrite && (params.edit || params.new);
|
||||
const [users, processes] = needsFormData
|
||||
? await Promise.all([
|
||||
db.user.findMany({
|
||||
where: { status: "ACTIVE" },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
db.process.findMany({ select: { id: true, name: true }, orderBy: { name: "asc" } }),
|
||||
])
|
||||
: [[], []];
|
||||
const availableAssets =
|
||||
canWrite && params.edit && modalRisk
|
||||
? await db.asset.findMany({
|
||||
where: { id: { notIn: modalRisk.riskAssets.map((ra) => ra.assetId) } },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: "asc" },
|
||||
})
|
||||
: [];
|
||||
|
||||
// Heatmap-Zellen: Auswirkung (Y, 5 oben) × Wahrscheinlichkeit (X)
|
||||
const cell = (likelihood: number, impact: number) =>
|
||||
risks.filter((r) => r.likelihood === likelihood && r.impact === impact);
|
||||
|
||||
const legend = [
|
||||
["low", t("legendLow")],
|
||||
["medium", t("legendMedium")],
|
||||
["elevated", t("legendElevated")],
|
||||
["high", t("legendHigh")],
|
||||
["critical", t("legendCritical")],
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<PageHead
|
||||
crumb={tAssets("crumb")}
|
||||
title={t("title")}
|
||||
sub={t("sub")}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" disabled title={tc("comingSoon")}>
|
||||
{t("export")}
|
||||
</Button>
|
||||
{canWrite && (
|
||||
<Button nativeButton={false} render={<Link href="/risks?new=1" />}>
|
||||
<Plus className="size-4" /> {t("newRisk")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
{/* 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">
|
||||
{[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">
|
||||
{impact}
|
||||
</div>
|
||||
{[1, 2, 3, 4, 5].map((likelihood) => {
|
||||
const cellRisks = cell(likelihood, impact);
|
||||
return (
|
||||
<div
|
||||
key={likelihood}
|
||||
className="flex min-h-14 flex-wrap content-start gap-1 rounded-md p-1.5"
|
||||
style={{ background: RISK_CELL_COLORS[riskLevel(likelihood * impact)] }}
|
||||
title={`${likelihood} × ${impact} = ${likelihood * impact}`}
|
||||
>
|
||||
{cellRisks.map((r) => (
|
||||
<Link
|
||||
key={r.id}
|
||||
href={`/risks?detail=${r.id}`}
|
||||
title={r.title}
|
||||
className="rounded-md bg-white/85 px-1.5 py-0.5 font-heading text-[10.5px] font-bold text-foreground shadow-sm hover:bg-white"
|
||||
>
|
||||
{riskRef(r.refNo)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
<div />
|
||||
{[1, 2, 3, 4, 5].map((l) => (
|
||||
<div key={l} className="text-center text-[11px] font-bold text-muted-foreground">
|
||||
{l}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between text-[11px] text-muted-foreground">
|
||||
<span>{t("axisY")}</span>
|
||||
<span>{t("axisX")}</span>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-3 text-[11.5px] text-muted-foreground">
|
||||
{legend.map(([key, label]) => (
|
||||
<span key={key} className="inline-flex items-center gap-1.5">
|
||||
<i
|
||||
className="inline-block size-3 rounded-[4px]"
|
||||
style={{ background: RISK_CELL_COLORS[key] }}
|
||||
/>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Register */}
|
||||
<div className="shadow-card rounded-xl border bg-card">
|
||||
<div className="p-4 pb-0">
|
||||
<SectTitle title={t("register")} sub={t("registerSub")} />
|
||||
</div>
|
||||
<Table className="mt-1">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("id")}</TableHead>
|
||||
<TableHead>{t("risk")}</TableHead>
|
||||
<TableHead>{t("rating")}</TableHead>
|
||||
<TableHead>{t("treatment")}</TableHead>
|
||||
<TableHead>{t("status")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{risks.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="py-8 text-center text-muted-foreground">
|
||||
{t("empty")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{risks.map((risk) => (
|
||||
<TableRow key={risk.id}>
|
||||
<TableCell className="text-muted-foreground">{riskRef(risk.refNo)}</TableCell>
|
||||
<TableCell>
|
||||
<Link href={`/risks?detail=${risk.id}`} className="font-bold hover:underline">
|
||||
{risk.title}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Pill tone={RISK_PILL_TONE[riskLevel(risk.score)]}>
|
||||
{t("scoreLabel", { score: risk.score, level: tLevel(riskLevel(risk.score)) })}
|
||||
</Pill>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Tag>{tTreat(risk.treatment)}</Tag>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Pill tone={STATUS_TONE[risk.status]}>{tStatus(risk.status)}</Pill>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{modalRisk && params.edit && canWrite ? (
|
||||
<RiskEditModal
|
||||
risk={modalRisk}
|
||||
users={users}
|
||||
processes={processes}
|
||||
availableAssets={availableAssets}
|
||||
/>
|
||||
) : modalRisk ? (
|
||||
<RiskDetailModal risk={modalRisk} canWrite={canWrite} />
|
||||
) : params.new && canWrite ? (
|
||||
<RiskCreateModal users={users} processes={processes} />
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user