From 668e58788167beb6862b36b6db9c7e90f310671d Mon Sep 17 00:00:00 2001 From: Martin Date: Thu, 2 Jul 2026 16:07:59 +0200 Subject: [PATCH] =?UTF-8?q?Iteration=204=20(Ma=C3=9Fnahmen)=20+=20Risikoan?= =?UTF-8?q?alyse=20nachgesch=C3=A4rft?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- messages/de.json | 48 ++- messages/en.json | 48 ++- package-lock.json | 40 +++ package.json | 1 + .../20260702135246_measures/migration.sql | 59 ++++ .../20260702135301_rls_measures/migration.sql | 9 + prisma/schema.prisma | 68 +++- prisma/seed.ts | 104 +++++++ src/app/(app)/layout.tsx | 2 +- src/app/(app)/measures/page.tsx | 133 ++++++++ src/app/(app)/risks/page.tsx | 30 +- src/components/asset-modals.tsx | 17 +- src/components/kanban-board.tsx | 166 ++++++++++ src/components/measure-modals.tsx | 274 +++++++++++++++++ src/components/risk-modals.tsx | 290 +++++++++++++----- src/lib/measure.ts | 15 + src/server/actions/measures.ts | 261 ++++++++++++++++ src/server/actions/risks.ts | 31 +- src/server/db.ts | 2 + src/server/risk-calc.ts | 38 +++ 20 files changed, 1534 insertions(+), 102 deletions(-) create mode 100644 prisma/migrations/20260702135246_measures/migration.sql create mode 100644 prisma/migrations/20260702135301_rls_measures/migration.sql create mode 100644 src/app/(app)/measures/page.tsx create mode 100644 src/components/kanban-board.tsx create mode 100644 src/components/measure-modals.tsx create mode 100644 src/lib/measure.ts create mode 100644 src/server/actions/measures.ts create mode 100644 src/server/risk-calc.ts diff --git a/messages/de.json b/messages/de.json index e4607e4..518d086 100644 --- a/messages/de.json +++ b/messages/de.json @@ -243,7 +243,19 @@ "legendHigh": "Hoch", "legendCritical": "Kritisch", "axisX": "Eintrittswahrscheinlichkeit →", - "axisY": "Auswirkung ↑" + "axisY": "Auswirkung ↑", + "likelihoodShort": "Eintrittswahrscheinlichkeit", + "damageShort": "Schaden", + "scoreShort": "Risikowert", + "residualAuto": "Ergibt sich automatisch aus den verknüpften Maßnahmen.", + "noMeasures": "Noch keine Maßnahmen verknüpft — das Rest-Risiko entspricht dem Brutto-Risiko.", + "linkMeasure": "Maßnahme verknüpfen", + "newMeasure": "Neue Maßnahme anlegen & verknüpfen", + "measureTitle": "Titel der Maßnahme", + "reductionL": "Minderung Wahrscheinlichkeit (0–4)", + "reductionI": "Minderung Schaden (0–4)", + "reduction": "Minderung", + "createFromAsset": "Risiko erstellen" }, "riskTreatment": { "AVOID": "Vermeiden", @@ -263,5 +275,39 @@ "elevated": "Erhöht", "high": "Hoch", "critical": "Kritisch" + }, + "measures": { + "title": "Aufgaben & Maßnahmen", + "sub": "Kanban-Board · Maßnahmen aus Risiken, Audits und Vorfällen", + "crumb": "Betrieb", + "newMeasure": "Maßnahme", + "board": "Maßnahmen-Board", + "empty": "Keine Maßnahmen.", + "detailHeading": "Maßnahme · {ref}", + "detailSub": "Status, Verantwortung & verknüpfte Risiken", + "editTitle": "Maßnahme bearbeiten", + "createTitle": "Maßnahme anlegen", + "titleField": "Titel", + "description": "Beschreibung", + "status": "Status", + "priority": "Priorität", + "owner": "Verantwortlich", + "dueDate": "Fällig am", + "linkedRisks": "Verknüpfte Risiken", + "linkedRisksNote": "Diese Maßnahme mindert folgende Risiken", + "noRisks": "Keine Risiken verknüpft.", + "close": "Schließen", + "overdue": "überfällig", + "dragHint": "Karten per Drag-and-Drop zwischen den Spalten verschieben." + }, + "measureStatus": { + "OPEN": "Offen", + "IN_PROGRESS": "In Umsetzung", + "DONE": "Erledigt" + }, + "measurePriority": { + "LOW": "Niedrig", + "MEDIUM": "Mittel", + "HIGH": "Hoch" } } \ No newline at end of file diff --git a/messages/en.json b/messages/en.json index fb20931..3c486d9 100644 --- a/messages/en.json +++ b/messages/en.json @@ -243,7 +243,19 @@ "legendHigh": "High", "legendCritical": "Critical", "axisX": "Likelihood →", - "axisY": "Impact ↑" + "axisY": "Impact ↑", + "likelihoodShort": "Likelihood", + "damageShort": "Damage", + "scoreShort": "Risk score", + "residualAuto": "Derived automatically from the linked measures.", + "noMeasures": "No measures linked yet — the residual risk equals the gross risk.", + "linkMeasure": "Link measure", + "newMeasure": "Create & link new measure", + "measureTitle": "Measure title", + "reductionL": "Reduction likelihood (0–4)", + "reductionI": "Reduction damage (0–4)", + "reduction": "Reduction", + "createFromAsset": "Create risk" }, "riskTreatment": { "AVOID": "Avoid", @@ -263,5 +275,39 @@ "elevated": "Elevated", "high": "High", "critical": "Critical" + }, + "measures": { + "title": "Tasks & measures", + "sub": "Kanban board · measures from risks, audits and incidents", + "crumb": "Operations", + "newMeasure": "Measure", + "board": "Measures board", + "empty": "No measures.", + "detailHeading": "Measure · {ref}", + "detailSub": "Status, responsibility & linked risks", + "editTitle": "Edit measure", + "createTitle": "Create measure", + "titleField": "Title", + "description": "Description", + "status": "Status", + "priority": "Priority", + "owner": "Responsible", + "dueDate": "Due date", + "linkedRisks": "Linked risks", + "linkedRisksNote": "This measure reduces the following risks", + "noRisks": "No risks linked.", + "close": "Close", + "overdue": "overdue", + "dragHint": "Drag cards between columns to change status." + }, + "measureStatus": { + "OPEN": "Open", + "IN_PROGRESS": "In progress", + "DONE": "Done" + }, + "measurePriority": { + "LOW": "Low", + "MEDIUM": "Medium", + "HIGH": "High" } } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 417c69b..cfc8692 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@base-ui/react": "^1.6.0", + "@dnd-kit/core": "^6.3.1", "@node-rs/argon2": "^2.0.2", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", @@ -555,6 +556,45 @@ } } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@dotenvx/dotenvx": { "version": "1.75.1", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.75.1.tgz", diff --git a/package.json b/package.json index b2b9026..9d6f2fa 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@base-ui/react": "^1.6.0", + "@dnd-kit/core": "^6.3.1", "@node-rs/argon2": "^2.0.2", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", diff --git a/prisma/migrations/20260702135246_measures/migration.sql b/prisma/migrations/20260702135246_measures/migration.sql new file mode 100644 index 0000000..d55f998 --- /dev/null +++ b/prisma/migrations/20260702135246_measures/migration.sql @@ -0,0 +1,59 @@ +-- CreateEnum +CREATE TYPE "MeasureStatus" AS ENUM ('OPEN', 'IN_PROGRESS', 'DONE'); + +-- CreateEnum +CREATE TYPE "MeasurePriority" AS ENUM ('LOW', 'MEDIUM', 'HIGH'); + +-- CreateTable +CREATE TABLE "measures" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "ref_no" INTEGER NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "status" "MeasureStatus" NOT NULL DEFAULT 'OPEN', + "priority" "MeasurePriority" NOT NULL DEFAULT 'MEDIUM', + "owner_id" TEXT, + "due_date" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "created_by" TEXT, + + CONSTRAINT "measures_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "risk_measures" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "risk_id" TEXT NOT NULL, + "measure_id" TEXT NOT NULL, + "reduction_likelihood" INTEGER NOT NULL DEFAULT 0, + "reduction_impact" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "risk_measures_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "measures_tenant_id_idx" ON "measures"("tenant_id"); + +-- CreateIndex +CREATE INDEX "measures_tenant_id_status_idx" ON "measures"("tenant_id", "status"); + +-- CreateIndex +CREATE UNIQUE INDEX "measures_tenant_id_ref_no_key" ON "measures"("tenant_id", "ref_no"); + +-- CreateIndex +CREATE INDEX "risk_measures_tenant_id_idx" ON "risk_measures"("tenant_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "risk_measures_risk_id_measure_id_key" ON "risk_measures"("risk_id", "measure_id"); + +-- AddForeignKey +ALTER TABLE "measures" ADD CONSTRAINT "measures_owner_id_fkey" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "risk_measures" ADD CONSTRAINT "risk_measures_risk_id_fkey" FOREIGN KEY ("risk_id") REFERENCES "risks"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "risk_measures" ADD CONSTRAINT "risk_measures_measure_id_fkey" FOREIGN KEY ("measure_id") REFERENCES "measures"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260702135301_rls_measures/migration.sql b/prisma/migrations/20260702135301_rls_measures/migration.sql new file mode 100644 index 0000000..c6708f4 --- /dev/null +++ b/prisma/migrations/20260702135301_rls_measures/migration.sql @@ -0,0 +1,9 @@ +-- RLS-Policies für die Maßnahmen-Tabellen + +ALTER TABLE "measures" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "risk_measures" ENABLE ROW LEVEL SECURITY; + +CREATE POLICY tenant_isolation ON "measures" + USING ("tenant_id" = current_setting('app.tenant_id', true)); +CREATE POLICY tenant_isolation ON "risk_measures" + USING ("tenant_id" = current_setting('app.tenant_id', true)); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 76d88ca..a73f817 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -61,6 +61,7 @@ model User { ownedAssets Asset[] @relation("assetOwner") ownedProcesses Process[] @relation("processOwner") ownedRisks Risk[] @relation("riskOwner") + ownedMeasures Measure[] @relation("measureOwner") @@unique([tenantId, email]) @@index([tenantId]) @@ -292,9 +293,10 @@ model Risk { updatedAt DateTime @updatedAt @map("updated_at") createdBy String? @map("created_by") - owner User? @relation("riskOwner", fields: [ownerId], references: [id]) - process Process? @relation(fields: [processId], references: [id]) - riskAssets RiskAsset[] + owner User? @relation("riskOwner", fields: [ownerId], references: [id]) + process Process? @relation(fields: [processId], references: [id]) + riskAssets RiskAsset[] + riskMeasures RiskMeasure[] @@unique([tenantId, refNo]) @@index([tenantId]) @@ -316,6 +318,66 @@ model RiskAsset { @@map("risk_assets") } +// ── Maßnahmen (SPEC §4.4) ─────────────────────────────────────────────────── + +enum MeasureStatus { + OPEN + IN_PROGRESS + DONE +} + +enum MeasurePriority { + LOW + MEDIUM + HIGH +} + +model Measure { + id String @id @default(cuid()) + tenantId String @map("tenant_id") + refNo Int @map("ref_no") // Anzeige als "M-001" + + title String + description String? + status MeasureStatus @default(OPEN) + priority MeasurePriority @default(MEDIUM) + ownerId String? @map("owner_id") + dueDate DateTime? @map("due_date") + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + createdBy String? @map("created_by") + + owner User? @relation("measureOwner", fields: [ownerId], references: [id]) + riskMeasures RiskMeasure[] + + @@unique([tenantId, refNo]) + @@index([tenantId]) + @@index([tenantId, status]) + @@map("measures") +} + +// Verknüpfung Risiko ↔ Maßnahme inkl. erwarteter Risikominderung: +// Das Rest-Risiko wird aus likelihood/impact minus der Summe der +// Minderungen aller verknüpften Maßnahmen berechnet (siehe src/server/risk-calc.ts). +model RiskMeasure { + id String @id @default(cuid()) + tenantId String @map("tenant_id") + riskId String @map("risk_id") + measureId String @map("measure_id") + + // Minderung in Stufen (0–4) je Dimension + reductionLikelihood Int @default(0) @map("reduction_likelihood") + reductionImpact Int @default(0) @map("reduction_impact") + + risk Risk @relation(fields: [riskId], references: [id], onDelete: Cascade) + measure Measure @relation(fields: [measureId], references: [id], onDelete: Cascade) + + @@unique([riskId, measureId]) + @@index([tenantId]) + @@map("risk_measures") +} + model AuditLog { id String @id @default(cuid()) tenantId String @map("tenant_id") diff --git a/prisma/seed.ts b/prisma/seed.ts index e48ad77..d7e0078 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -303,6 +303,110 @@ async function main() { } console.log(`✔ ${risks.length} Beispiel-Risiken angelegt`); } + + // 7. Beispiel-Maßnahmen (nur wenn noch keine existieren) — bestimmen das Rest-Risiko + const measureCount = await prisma.measure.count({ where: { tenantId: tenant.id } }); + if (measureCount === 0) { + const owner = await prisma.user.findUniqueOrThrow({ + where: { tenantId_email: { tenantId: tenant.id, email: "owner@demo.example" } }, + }); + const riskByTitle = (title: string) => + prisma.risk.findFirst({ where: { tenantId: tenant.id, title } }); + + const measures: { + title: string; + status: "OPEN" | "IN_PROGRESS" | "DONE"; + priority: "LOW" | "MEDIUM" | "HIGH"; + dueInDays?: number; + riskTitle?: string; + reduction?: [number, number]; // [Wahrscheinlichkeit, Schaden] + }[] = [ + { + title: "Offline-Backups einführen (3-2-1-Regel)", + status: "IN_PROGRESS", + priority: "HIGH", + dueInDays: 14, + riskTitle: "Ransomware auf ERP", + reduction: [1, 1], + }, + { + title: "Makro-Ausführung per GPO einschränken", + status: "OPEN", + priority: "HIGH", + dueInDays: 30, + riskTitle: "Ransomware auf ERP", + reduction: [1, 0], + }, + { + title: "Awareness-Schulung Phishing (alle Mitarbeitenden)", + status: "IN_PROGRESS", + priority: "MEDIUM", + dueInDays: 45, + riskTitle: "Phishing / Social Engineering", + reduction: [1, 0], + }, + { + title: "Notfallhandbuch aktualisieren", + status: "DONE", + priority: "MEDIUM", + }, + ]; + + let mRef = 1; + for (const m of measures) { + const measure = await prisma.measure.create({ + data: { + tenantId: tenant.id, + refNo: mRef++, + title: m.title, + status: m.status, + priority: m.priority, + ownerId: owner.id, + dueDate: m.dueInDays + ? new Date(Date.now() + m.dueInDays * 24 * 3600 * 1000) + : undefined, + }, + }); + if (m.riskTitle && m.reduction) { + const risk = await riskByTitle(m.riskTitle); + if (risk) { + await prisma.riskMeasure.create({ + data: { + tenantId: tenant.id, + riskId: risk.id, + measureId: measure.id, + reductionLikelihood: m.reduction[0], + reductionImpact: m.reduction[1], + }, + }); + } + } + } + + // Rest-Risiken aus den Maßnahmen ableiten (manuelle Alt-Werte ersetzen) + const allRisks = await prisma.risk.findMany({ + where: { tenantId: tenant.id }, + include: { riskMeasures: true }, + }); + for (const r of allRisks) { + if (r.riskMeasures.length === 0) { + await prisma.risk.update({ + where: { id: r.id }, + data: { residualLikelihood: null, residualImpact: null, residualScore: null }, + }); + } else { + const redL = r.riskMeasures.reduce((s, x) => s + x.reductionLikelihood, 0); + const redI = r.riskMeasures.reduce((s, x) => s + x.reductionImpact, 0); + const rl = Math.max(1, r.likelihood - redL); + const ri = Math.max(1, r.impact - redI); + await prisma.risk.update({ + where: { id: r.id }, + data: { residualLikelihood: rl, residualImpact: ri, residualScore: rl * ri }, + }); + } + } + console.log(`✔ ${measures.length} Beispiel-Maßnahmen angelegt, Rest-Risiken berechnet`); + } } main() diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index 9d808f4..88bebd3 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -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 }, diff --git a/src/app/(app)/measures/page.tsx b/src/app/(app)/measures/page.tsx new file mode 100644 index 0000000..9108443 --- /dev/null +++ b/src/app/(app)/measures/page.tsx @@ -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 ( +
+ }> + {t("newMeasure")} + + ) + } + /> + + {canWrite && ( +

{t("dragHint")}

+ )} + + + + {measures.length === 0 && ( +

{t("empty")}

+ )} + + {modalMeasure && params.edit && canWrite ? ( + + ) : modalMeasure ? ( + + ) : params.new && canWrite ? ( + + ) : null} +
+ ); +} diff --git a/src/app/(app)/risks/page.tsx b/src/app/(app)/risks/page.tsx index 4e114ef..d7b2c15 100644 --- a/src/app/(app)/risks/page.tsx +++ b/src/app/(app)/risks/page.tsx @@ -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= + 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({ } /> -
+
{/* Heatmap */}
-
+
{[5, 4, 3, 2, 1].map((impact) => (
@@ -241,11 +262,12 @@ export default async function RisksPage({ users={users} processes={processes} availableAssets={availableAssets} + availableMeasures={availableMeasures} /> ) : modalRisk ? ( ) : params.new && canWrite ? ( - + ) : null} ); diff --git a/src/components/asset-modals.tsx b/src/components/asset-modals.tsx index b5edb24..1dead85 100644 --- a/src/components/asset-modals.tsx +++ b/src/components/asset-modals.tsx @@ -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 */}
- {t("linkedRisks")} +
+ {t("linkedRisks")} + {canWrite && ( + + )} +
{asset.riskAssets.length === 0 &&

{t("linkedRisksPlaceholder")}

}
    {asset.riskAssets.map((ra) => ( diff --git a/src/components/kanban-board.tsx b/src/components/kanban-board.tsx new file mode 100644 index 0000000..6c09066 --- /dev/null +++ b/src/components/kanban-board.tsx @@ -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; +}) { + 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 ( + +
    + {columns.map((col) => ( + router.push(`/measures?detail=${id}`)} /> + ))} +
    +
    + ); +} + +function Column({ + column, + canWrite, + onOpen, +}: { + column: KanbanColumn; + canWrite: boolean; + onOpen: (id: string) => void; +}) { + const { setNodeRef, isOver } = useDroppable({ id: column.status }); + + return ( +
    +
    + {column.label} + + {column.cards.length} + +
    +
    + {column.cards.map((card) => ( + + ))} +
    +
    + ); +} + +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 ( +
    !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" + )} + > +
    + {card.refLabel} + + {card.priorityLabel} + +
    +

    {card.title}

    +
    + + {card.dueLabel ?? "—"} + + + {card.riskCount > 0 && ⚠ {card.riskCount}} + {card.ownerInitials && ( + + {card.ownerInitials} + + )} + +
    +
    + ); +} diff --git a/src/components/measure-modals.tsx b/src/components/measure-modals.tsx new file mode 100644 index 0000000..864025c --- /dev/null +++ b/src/components/measure-modals.tsx @@ -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 ( + + {tPrio(measure.priority)} + {tStatus(measure.status)} + + } + closeHref="/measures" + closeLabel={t("close")} + footer={ + <> + {canWrite && ( + + )} + + + } + > +
    +
    + {measure.title} + {measure.description && ( +

    {measure.description}

    + )} +
    +
    {t("owner")}
    +
    {measure.owner?.name ?? tc("none")}
    +
    {t("dueDate")}
    +
    + {measure.dueDate + ? format.dateTime(measure.dueDate, { dateStyle: "medium" }) + : tc("none")} +
    +
    +
    + +
    +
    + {t("linkedRisks")} + {t("linkedRisksNote")} +
    + {measure.riskMeasures.length === 0 && ( +

    {t("noRisks")}

    + )} +
      + {measure.riskMeasures.map((rm) => ( +
    • + + {riskRef(rm.risk.refNo)} + + + {rm.risk.title} + + + {rm.risk.score} · {tLevel(riskLevel(rm.risk.score))} + + + {tr("reduction")}: −{rm.reductionLikelihood} / −{rm.reductionImpact} + +
    • + ))} +
    +
    +
    +
    + ); +} + +/** 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 ( + <> +
    + + +
    +
    + +