Fundament: ISMS-Module entfernt; Craftvia-Rollen, Module, Navigation, i18n-Split

- ISMS-Routen, Actions, Server-/Lib-Code, Komponenten, Prisma-Modelle, Seeds,
  Importer, Skripte und ISMS-Tests entfernt (Fundament bleibt: Auth, Identity,
  MFA/WebAuthn, RBAC, Audit, Mail, Storage, Backup/DSGVO, Plattform-Admin)
- Schema auf Fundament-Modelle reduziert; TenantSettings generisch (+phone/email)
- TENANT_MODELS (db.ts, backup/topology.ts) und PII-Felder ausgedünnt
- RBAC: Rollen tenant-admin/backoffice/team-lead/technician + Craftvia-Permissions
- Modul-Katalog (customers, sites, teams, work_orders, imports, field, reports,
  emergency, documents, notifications, lotse) + Navigation aus src/lib/nav.ts
- Modul-Routen mit requireModule-Layout und Platzhalterseite
- Message-Katalog je Namespace (messages/<locale>/<namespace>.json), fs-Loader
- check-module-guards: Modul-Key aus src/server/actions/<moduleKey>/
- Provisionierung, Admin-Konsole, Einstellungen, Files-Route, Mail entkoppelt
- Seed minimal (demo/demo2, Nutzer je Rolle); Fundament-Tests auf Role/
  NotificationPreference-Fixtures umgestellt

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 11:35:44 +02:00
co-authored by Claude Opus 5
parent c8e6f30a27
commit 8491c7f173
443 changed files with 1325 additions and 87773 deletions
-129
View File
@@ -1,129 +0,0 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import type { Asset, AssetType } from "@prisma/client";
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 { SegmentedRating } from "@/components/segmented-rating";
const ASSET_TYPES = ["INFORMATION", "SYSTEM", "APPLICATION", "LOCATION", "SUPPLIER", "IT_SERVICE", "PERSON", "DATA"] as const;
const ASSET_STATUS = ["ACTIVE", "PLANNED", "RETIRED"] as const;
/** Formular für Anlegen/Bearbeiten eines Assets (Server-Action wird übergeben). */
export async function AssetForm({
action,
asset,
users,
cancelHref,
defaultType = "SYSTEM",
}: {
action: (formData: FormData) => Promise<void>;
asset?: Asset;
users: { id: string; name: string }[];
cancelHref: string;
/** Vorbelegter Typ beim Anlegen (z. B. INFORMATION im BIA-Popup Schritt 1). */
defaultType?: AssetType;
}) {
const t = await getTranslations("assets");
const tType = await getTranslations("assetType");
const tStatus = await getTranslations("assetStatus");
const tLevel = await getTranslations("protectionLevel");
const tc = await getTranslations("common");
const selectClass =
"h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm";
return (
<form action={action} className="max-w-2xl space-y-4">
<div>
<Label htmlFor="name">{t("name")}</Label>
<Input id="name" name="name" required defaultValue={asset?.name} className="mt-1" />
</div>
<div>
<Label htmlFor="description">{t("description")}</Label>
<Textarea
id="description"
name="description"
rows={3}
defaultValue={asset?.description ?? ""}
className="mt-1"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="type">{t("type")}</Label>
<select id="type" name="type" defaultValue={asset?.type ?? defaultType} className={`${selectClass} mt-1`}>
{ASSET_TYPES.map((v) => (
<option key={v} value={v}>
{tType(v)}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="status">{t("status")}</Label>
<select id="status" name="status" defaultValue={asset?.status ?? "ACTIVE"} className={`${selectClass} mt-1`}>
{ASSET_STATUS.map((v) => (
<option key={v} value={v}>
{tStatus(v)}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="ownerId">{t("owner")}</Label>
<select id="ownerId" name="ownerId" defaultValue={asset?.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="location">{t("location")}</Label>
<Input id="location" name="location" defaultValue={asset?.location ?? ""} className="mt-1" />
</div>
</div>
<fieldset>
<legend className="text-sm font-medium">{t("protection")} (1–4)</legend>
<div className="mt-2 grid grid-cols-3 gap-4">
{(
[
["confidentiality", t("confidentiality"), asset?.confidentiality],
["integrity", t("integrity"), asset?.integrity],
["availability", t("availability"), asset?.availability],
] as const
).map(([name, label, value]) => (
<div key={name}>
<Label>{label}</Label>
<div className="mt-1">
<SegmentedRating
name={name}
defaultValue={value ?? 1}
low={tLevel("1")}
high={tLevel("4")}
/>
</div>
</div>
))}
</div>
</fieldset>
<div>
<Label htmlFor="tags">{t("tags")}</Label>
<Input id="tags" name="tags" defaultValue={asset?.tags.join(", ") ?? ""} className="mt-1" />
</div>
<div className="flex gap-2 pt-2">
<Button type="submit">{tc("save")}</Button>
<Button variant="outline" nativeButton={false} render={<Link href={cancelHref} />}>
{tc("cancel")}
</Button>
</div>
</form>
);
}
-333
View File
@@ -1,333 +0,0 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Network, Pencil, Plus, Trash2, X } from "lucide-react";
import type { Prisma } from "@prisma/client";
import {
addAssetRelation,
createAsset,
deleteAsset,
removeAssetRelation,
updateAsset,
} from "@/server/actions/assets";
import { Button } from "@/components/ui/button";
import { Modal } from "@/components/modal";
import { AssetForm } from "@/components/asset-form";
import { CiaBadge, CiaLegend, Pill, Tag } from "@/components/mockup-ui";
import { riskLevel, riskRef, RISK_PILL_TONE } from "@/lib/risk";
export type AssetWithRelations = Prisma.AssetGetPayload<{
include: {
owner: { select: { 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 } } };
};
};
}>;
const STATUS_TONE = { ACTIVE: "ok", PLANNED: "info", RETIRED: "mut" } as const;
/** Read-only-Detailansicht als Popup — optisch analog zum Prozess-Detail. */
export async function AssetDetailModal({
asset,
canWrite,
}: {
asset: AssetWithRelations;
canWrite: boolean;
}) {
const t = await getTranslations("assets");
const tType = await getTranslations("assetType");
const tStatus = await getTranslations("assetStatus");
const tRole = await getTranslations("processRole");
const tLevel = await getTranslations("riskLevel");
const tRisks = await getTranslations("risks");
const tDep = await getTranslations("dependencies");
const tc = await getTranslations("common");
const tp = await getTranslations("processes");
return (
<Modal
title={asset.name}
sub={t("detailSub")}
headerExtra={
<span className="flex items-center gap-2">
<Tag>{tType(asset.type)}</Tag>
<Pill tone={STATUS_TONE[asset.status]}>{tStatus(asset.status)}</Pill>
</span>
}
closeHref="/assets"
closeLabel={tp("close")}
footer={
<>
{canWrite && (
<Button
variant="outline"
nativeButton={false}
render={<Link href={`/assets?edit=${asset.id}`} />}
>
<Pencil className="size-4" /> {tc("edit")}
</Button>
)}
<Button nativeButton={false} render={<Link href="/assets" />}>
{tp("close")}
</Button>
</>
}
>
<div className="grid gap-5 p-5 md:grid-cols-2">
{/* Stammdaten — violette Karte wie das primäre Asset im Prozess-Popup */}
<div>
<div className="mb-2.5 flex items-center gap-2.5">
<Pill tone="violet">{t("masterPill")}</Pill>
<span className="text-[12.5px] text-muted-foreground">{t("masterNote")}</span>
</div>
<div className="rounded-xl border border-l-[3px] border-l-[var(--primary)] bg-[var(--surface-soft)] p-4">
<div className="flex items-center justify-between gap-2">
<b>{asset.name}</b>
<CiaBadge c={asset.confidentiality} i={asset.integrity} a={asset.availability} labels />
</div>
<div className="mt-1.5 text-[12.5px] leading-relaxed text-muted-foreground">
{t("owner")}: {asset.owner?.name ?? tc("none")} · {t("type")}: {tType(asset.type)}
{asset.location ? ` · ${t("location")}: ${asset.location}` : ""}
</div>
{asset.description && (
<p className="mt-2 text-[12.5px] leading-relaxed">{asset.description}</p>
)}
{asset.tags.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{asset.tags.map((tag) => (
<Tag key={tag}>{tag}</Tag>
))}
</div>
)}
</div>
<div className="mt-4">
<p className="text-sm font-semibold">{t("processes")}</p>
{asset.processAssets.length === 0 && (
<p className="mt-1 text-sm text-muted-foreground">{tc("none")}</p>
)}
<ul className="mt-1.5 space-y-1.5 text-sm">
{asset.processAssets.map((pa) => (
<li key={pa.id} className="flex items-center gap-2">
<Link href={`/processes?detail=${pa.process.id}`} className="hover:underline">
{pa.process.name}
</Link>
<Pill tone={pa.role === "PRIMARY" ? "violet" : "info"}>{tRole(pa.role)}</Pill>
</li>
))}
</ul>
</div>
</div>
{/* Abhängigkeiten — Tabelle wie die sekundären Assets im Prozess-Popup */}
<div>
<div className="mb-2.5 flex items-center gap-2.5">
<Pill tone="info">{t("depPill")}</Pill>
<span className="text-[12.5px] text-muted-foreground">{t("depNote")}</span>
</div>
<p className="text-[12.5px] text-muted-foreground">{t("relationHint")}</p>
{asset.relationsFrom.length === 0 && (
<p className="text-sm text-muted-foreground">{tc("none")}</p>
)}
<table className="w-full text-sm">
<thead>
<tr>
<th />
<th />
<th className="pb-1 text-right font-normal">
<CiaLegend />
</th>
</tr>
</thead>
<tbody>
{asset.relationsFrom.map((rel) => (
<tr key={rel.id} className="border-b last:border-0">
<td className="py-2.5 pr-2 font-bold">
<Link href={`/assets?detail=${rel.relatedAsset.id}`} className="hover:underline">
{rel.relatedAsset.name}
</Link>
</td>
<td className="py-2.5 pr-2">
<Tag>{tType(rel.relatedAsset.type)}</Tag>
</td>
<td className="py-2.5 text-right">
<CiaBadge
c={rel.relatedAsset.confidentiality}
i={rel.relatedAsset.integrity}
a={rel.relatedAsset.availability}
/>
</td>
</tr>
))}
</tbody>
</table>
<p className="mt-3 text-[12.5px] text-muted-foreground">{t("relationReverseHint")}</p>
{asset.relationsTo.length === 0 && (
<p className="text-sm text-muted-foreground">{tc("none")}</p>
)}
<ul className="mt-1 space-y-1 text-sm">
{asset.relationsTo.map((rel) => (
<li key={rel.id}>
<Link href={`/assets?detail=${rel.asset.id}`} className="hover:underline">
{rel.asset.name}
</Link>
</li>
))}
</ul>
<Link
href="/dependencies"
className="mt-3 inline-flex items-center gap-1.5 text-[12.5px] font-semibold text-[var(--info)] hover:underline"
>
<Network className="size-3.5" /> {tDep("openGraph")} →
</Link>
</div>
</div>
{/* Zugeordnete Risiken — Band wie die BIA-Kennzahlen im Prozess-Popup */}
<div className="mx-5 mb-5 rounded-xl border border-[var(--band-brd)] bg-[var(--band)] p-4 text-[12.5px] text-[var(--band-text)]">
<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) => (
<li key={ra.id} className="flex items-center gap-2">
<Link href={`/risks?detail=${ra.risk.id}`} className="font-bold hover:underline">
{riskRef(ra.risk.refNo)}
</Link>
<Link href={`/risks?detail=${ra.risk.id}`} className="hover:underline">
{ra.risk.title}
</Link>
<Pill tone={RISK_PILL_TONE[riskLevel(ra.risk.score)]}>
{ra.risk.score} · {tLevel(riskLevel(ra.risk.score))}
</Pill>
</li>
))}
</ul>
</div>
</Modal>
);
}
/** Bearbeiten als Popup: Stammdaten-Formular + Abhängigkeiten + Löschen. */
export async function AssetEditModal({
asset,
users,
otherAssets,
}: {
asset: AssetWithRelations;
users: { id: string; name: string }[];
otherAssets: { id: string; name: string }[];
}) {
const t = await getTranslations("assets");
const tc = await getTranslations("common");
const tp = await getTranslations("processes");
return (
<Modal
title={t("editTitle")}
sub={asset.name}
closeHref={`/assets?detail=${asset.id}`}
closeLabel={tp("close")}
>
<div className="grid gap-5 p-5 md:grid-cols-[1fr_16rem]">
<AssetForm
action={updateAsset.bind(null, asset.id)}
asset={asset}
users={users}
cancelHref={`/assets?detail=${asset.id}`}
/>
<div>
<p className="text-sm font-semibold">{t("relations")}</p>
<p className="mt-1 text-[12.5px] text-muted-foreground">{t("relationHint")}</p>
<ul className="mt-2 space-y-1.5 text-sm">
{asset.relationsFrom.map((rel) => (
<li key={rel.id} className="flex items-center gap-2">
{rel.relatedAsset.name}
<form action={removeAssetRelation.bind(null, asset.id, rel.id)}>
<button
type="submit"
title={tc("remove")}
className="text-muted-foreground hover:text-destructive"
>
<X className="size-3.5" />
</button>
</form>
</li>
))}
</ul>
{otherAssets.length > 0 && (
<form action={addAssetRelation.bind(null, asset.id)} className="mt-3 space-y-2">
<select
name="relatedAssetId"
required
className="h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm"
>
{otherAssets.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
</option>
))}
</select>
<Button type="submit" variant="secondary" size="sm">
{t("addRelation")}
</Button>
</form>
)}
<div className="mt-6 border-t pt-4">
<form action={deleteAsset.bind(null, asset.id)}>
<Button type="submit" variant="destructive" size="sm">
<Trash2 className="size-4" /> {tc("delete")}
</Button>
</form>
</div>
</div>
</div>
</Modal>
);
}
/** Anlegen als Popup. */
export async function AssetCreateModal({
users,
}: {
users: { id: string; name: string }[];
}) {
const t = await getTranslations("assets");
const tp = await getTranslations("processes");
return (
<Modal title={t("createTitle")} closeHref="/assets" closeLabel={tp("close")}>
<div className="p-5">
<AssetForm action={createAsset} users={users} cancelHref="/assets" />
</div>
</Modal>
);
}
+13 -11
View File
@@ -47,23 +47,25 @@ const ACTION_TONE: Record<string, "ok" | "warn" | "mut" | "info"> = {
};
const ENTITY_LABEL: Record<string, string> = {
asset: "Asset",
process: "Prozess",
risk: "Risiko",
measure: "Maßnahme",
supplier: "Lieferant",
software: "Software",
project: "Projekt",
service: "IT-Dienst",
policy: "Richtlinie",
task: "Aufgabe",
user: "Benutzer",
role: "Rolle",
tenant: "Mandant",
tenant_settings: "Einstellungen",
tenant_module: "Modul",
tenant_locale: "Mandantensprache",
tenant_mfa_policy: "MFA-Pflicht",
module: "Modul",
session: "Sitzung",
bia: "BIA",
platformAdmin: "Plattform-Admin",
// Craftvia-Fachobjekte (Labels vorab, Module folgen)
customer: "Kunde",
site: "Objekt",
team: "Team",
work_order: "Auftrag",
import: "Auftragsimport",
report: "Bericht",
emergency: "Notdienst",
document: "Dokument",
};
const fmt = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium", timeStyle: "short" });
-735
View File
@@ -1,735 +0,0 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Check, ChevronLeft, ChevronRight, Plus, X } from "lucide-react";
import { requireSession } from "@/server/auth";
import { dbForTenant } from "@/server/db";
import { hasPermission } from "@/server/rbac";
import { Modal } from "@/components/modal";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { CiaBadge, CiaLegend, CriticalityPill, Pill, Tag } from "@/components/mockup-ui";
import { SegmentedRating } from "@/components/segmented-rating";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { AssetForm } from "@/components/asset-form";
import { InformationCombobox } from "@/app/(app)/onboarding/steps/information/information-combobox";
import { assignAsset, setBiaStatus, unassignAsset } from "@/server/actions/processes";
import { createPrimaryInformationAsset, createSecondaryCarrierAsset, saveInfoProtection } from "@/server/actions/structure";
import { adoptProcessRisk } from "@/server/actions/risk-catalog";
import { createProcessRisk, linkProcessRisk, rateProcessRisk } from "@/server/actions/risks";
import { riskLevel, riskRef, RISK_PILL_TONE } from "@/lib/risk";
import { assetTypeLabel, infoLabelLabel } from "@/lib/asset-labels";
const SCALE5 = [1, 2, 3, 4, 5] as const;
const TREATMENTS = ["AVOID", "MITIGATE", "TRANSFER", "ACCEPT"] as const;
const STATUSES = ["OPEN", "IN_TREATMENT", "ACCEPTED", "CLOSED"] as const;
const TOTAL_STEPS = 5;
const INFO_TYPES = ["INFORMATION", "DATA"] as const;
/**
* TISAX v3A — geführtes BIA-Popup je Prozess (Prozesshaus-Kachel öffnet es). Fünf
* Schritte, die die früheren eigenständigen Onboarding-Schritte an der Prozess-Matrix
* bündeln:
* 1 Informationswert (primäres Asset INFORMATION) — Katalog-Vorschlag oder volle
* Asset-Inventar-Maske (AssetForm) bzw. Dedup-Combobox.
* 2 Sekundäre Assets / Träger (ProcessAsset SECONDARY).
* 3 Schutzbedarf C/I/A am Informations-Asset (Maximumprinzip).
* 4 Risiken je Prozess inkl. Bewertung (Eintritt × Auswirkung = Wert, Behandlung).
* 5 Abschluss-Übersicht — kompletter Review, setzt process.biaStatus (Prozesshaus-Farbe).
*
* Server-gerendert, Zustand über searchParams (?bia=<id>&biaStep=1..5) — konsistent zum
* bestehenden Modal-Muster (kein Client-State; Browser-Zurück schließt/blättert).
*/
export async function BiaPopup({
processId,
step,
newForm,
basePath = "/onboarding?step=processes",
}: {
processId: string;
step: number;
/**
* Aktiver Teil-Dialog (searchParam `biaNew`): "info" (volle Info-Maske),
* "carrier" (neues Träger-Asset), "risk" (neues Risiko). Wird über die Hostseite
* durchgereicht; „Abbrechen" entfernt den Param wieder und schließt den Teil-Dialog.
*/
newForm?: string;
/** Basisroute der Hostseite (die searchParams `bia`/`biaStep` werden angehängt). */
basePath?: string;
}) {
const session = await requireSession();
const db = dbForTenant(session.user.tenantId);
const t = await getTranslations("bia");
const tc = await getTranslations("common");
const tAssets = await getTranslations("assets");
const tType = await getTranslations("assetType");
const tLevel = await getTranslations("protectionLevel");
const tTreat = await getTranslations("riskTreatment");
const tStatus = await getTranslations("riskStatus");
const tRiskLevel = await getTranslations("riskLevel");
const tCat = await getTranslations("processCategory");
const canWrite = hasPermission(session, "bia:write");
const process = await db.process.findFirst({
where: { id: processId },
include: {
owner: { select: { name: true } },
bia: true,
processAssets: {
include: {
asset: {
select: {
id: true,
name: true,
type: true,
label: true,
confidentiality: true,
integrity: true,
availability: true,
owner: { select: { name: true } },
},
},
},
},
},
});
const closeHref = basePath;
if (!process) {
return (
<Modal title={t("heading", { name: "?" })} closeHref={closeHref} closeLabel={tc("close")}>
<p className="p-5 text-sm text-muted-foreground">{tc("none")}</p>
</Modal>
);
}
const current = Math.min(Math.max(step, 1), TOTAL_STEPS);
const stepHref = (n: number) => `${basePath}&bia=${process.id}&biaStep=${n}`;
// Teil-Dialog öffnen (searchParam `biaNew`); der cancelHref ist stets `stepHref(n)`
// (ohne `biaNew`) — so schließt „Abbrechen" den Teil-Dialog sauber (Task 2).
const formHref = (n: number, name: string) => `${stepHref(n)}&biaNew=${name}`;
const primaries = process.processAssets.filter(
(pa) => pa.role === "PRIMARY" && INFO_TYPES.includes(pa.asset.type as (typeof INFO_TYPES)[number]),
);
const secondaries = process.processAssets.filter((pa) => pa.role === "SECONDARY");
// Geerbtes Maximum (Maximumprinzip) aus den primären Werten — für Träger-Vererbung.
const maxC = Math.max(1, ...primaries.map((pa) => pa.asset.confidentiality));
const maxI = Math.max(1, ...primaries.map((pa) => pa.asset.integrity));
const maxA = Math.max(1, ...primaries.map((pa) => pa.asset.availability));
const linkedRisks = await db.risk.findMany({
where: { processId: process.id },
select: { id: true, refNo: true, title: true, likelihood: true, impact: true, score: true, treatment: true, status: true },
orderBy: { score: "desc" },
});
const users = canWrite
? await db.user.findMany({ where: { status: "ACTIVE" }, select: { id: true, name: true }, orderBy: { name: "asc" } })
: [];
const availableAssets =
canWrite && current === 2
? await db.asset.findMany({
where: { id: { notIn: process.processAssets.map((pa) => pa.assetId) } },
select: { id: true, name: true, type: true },
orderBy: { name: "asc" },
})
: [];
// Bestehende Risiken, die diesem Prozess noch NICHT zugeordnet sind (Task 4a) —
// ungebundene (processId null) und an anderen Prozessen hängende (Umhängen erlaubt).
const availableRisks =
canWrite && current === 4
? await db.risk.findMany({
where: { OR: [{ processId: null }, { processId: { not: process.id } }] },
select: { id: true, refNo: true, title: true },
orderBy: { refNo: "asc" },
})
: [];
// Katalog-Vorschläge (über catalogCode / Name) für Träger-Typen. Info-Labels werden im
// Info-Schritt nicht mehr abgefragt (redundant zum C/I/A-Schritt, Task 1).
const catalog = process.catalogCode
? await db.processCatalogEntry.findFirst({ where: { code: process.catalogCode } })
: await db.processCatalogEntry.findFirst({ where: { name: process.name } });
const suggestedCarrierTypes = (catalog?.suggestedAssetTypes ?? []).filter(
(tp) => tp !== "INFORMATION" && tp !== "DATA",
);
// Empfohlene Katalog-Risiken je Prozess (nur bei aktivem Risiko-Modul); tolerant.
let suggestedRisks: { code: string; title: string; category: string; adopted: boolean }[] = [];
if (current === 4) {
const codes = catalog?.suggestedRiskCodes ?? [];
if (codes.length > 0) {
const [entries, adopted] = await Promise.all([
db.riskCatalogEntry.findMany({ where: { code: { in: codes } }, select: { code: true, title: true, category: true } }),
db.risk.findMany({ where: { catalogCode: { in: codes }, processId: process.id }, select: { catalogCode: true } }),
]);
const adoptedSet = new Set(adopted.map((r) => r.catalogCode));
suggestedRisks = entries.map((e) => ({ ...e, adopted: adoptedSet.has(e.code) }));
}
}
// Checkliste für Fortschritt/Abschluss.
const hasInfo = primaries.length > 0;
const hasCarrier = secondaries.length > 0;
const hasCia = primaries.some((pa) => pa.asset.confidentiality > 1 || pa.asset.integrity > 1 || pa.asset.availability > 1);
const hasRisk = linkedRisks.length > 0;
const doneCount = [hasInfo, hasCarrier, hasCia, hasRisk].filter(Boolean).length;
const stepDone = [hasInfo, hasCarrier, hasCia, hasRisk, process.biaStatus !== "offen"];
const selectClass = "h-9 rounded-md border border-input bg-transparent px-3 text-sm";
return (
<Modal
title={t("heading", { name: process.name })}
sub={t("stepOf", { n: current, total: TOTAL_STEPS })}
headerExtra={
<span className="flex items-center gap-2">
<Tag>{tCat(process.category)}</Tag>
<Pill tone={BIA_TONE[process.biaStatus] ?? "mut"}>{t(`status.${process.biaStatus}`)}</Pill>
</span>
}
closeHref={closeHref}
closeLabel={tc("close")}
footer={
<>
{current > 1 ? (
<Button variant="outline" nativeButton={false} render={<Link href={stepHref(current - 1)} />}>
<ChevronLeft className="size-4" /> {t("back")}
</Button>
) : (
<span />
)}
{current < TOTAL_STEPS && (
<Button nativeButton={false} render={<Link href={stepHref(current + 1)} />}>
{t("next")} <ChevronRight className="size-4" />
</Button>
)}
</>
}
>
{/* Schritt-Navigation (klickbar) */}
<div className="sticky top-0 z-10 flex flex-wrap gap-1.5 border-b bg-card/95 px-5 py-2.5 backdrop-blur">
{([1, 2, 3, 4, 5] as const).map((n) => (
<Link
key={n}
href={stepHref(n)}
className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 font-heading text-[12px] font-semibold ${
n === current
? "border-[var(--brand,var(--primary))] bg-[var(--surface-soft)] text-foreground"
: "text-muted-foreground hover:bg-secondary hover:text-foreground"
}`}
>
<span
className={`grid size-4.5 place-items-center rounded-full text-[10px] ${
stepDone[n - 1] ? "bg-[var(--ok)] text-white" : "border"
}`}
>
{stepDone[n - 1] ? <Check className="size-3" /> : n}
</span>
{t(`s${n}Short`)}
</Link>
))}
</div>
<div className="space-y-5 p-5">
{/* ── Schritt 1: Informationswert ─────────────────────────────────── */}
{current === 1 && (
<section className="space-y-4">
<div>
<p className="font-heading text-[15px] font-semibold">{t("s1Title")}</p>
<p className="mt-0.5 text-[12.5px] text-muted-foreground">{t("s1Hint")}</p>
</div>
<div>
<p className="mb-2 text-[13px] font-semibold">{t("currentPrimaries")}</p>
{primaries.length === 0 ? (
<p className="text-[12.5px] text-muted-foreground">{t("noPrimaries")}</p>
) : (
<ul className="space-y-2">
{primaries.map((pa) => (
<li
key={pa.id}
className="flex flex-wrap items-center gap-2 rounded-xl border border-l-[3px] border-l-[var(--primary)] bg-[var(--surface-soft)] p-3 text-sm"
>
<b className="flex-1">{pa.asset.name}</b>
<Tag>{tType(pa.asset.type)}</Tag>
{pa.asset.label !== "NONE" && <Pill tone="warn">{infoLabelLabel(pa.asset.label)}</Pill>}
<CiaBadge c={pa.asset.confidentiality} i={pa.asset.integrity} a={pa.asset.availability} />
{canWrite && (
<form action={unassignAsset.bind(null, process.id, pa.id)}>
<button type="submit" title={tc("remove")} className="text-muted-foreground hover:text-destructive">
<X className="size-3.5" />
</button>
</form>
)}
</li>
))}
</ul>
)}
</div>
{canWrite && (
<>
<div className="rounded-xl border bg-card p-3">
<p className="mb-2 text-[13px] font-semibold">{t("quickAdd")}</p>
{/* Task 1: Typ- (Information/Daten) und Klassifizierungs-Dropdown ausgeblendet —
im BIA-Info-Schritt ist es immer ein Informationswert (INFORMATION). */}
<InformationCombobox processId={process.id} hideType hideLabel />
</div>
{/* Task 2: Teil-Dialog über searchParam `biaNew=info`; „Abbrechen" (in der
AssetForm) zeigt auf stepHref(1) ohne den Param und schließt so sauber. */}
{newForm === "info" ? (
<div className="rounded-xl border bg-card p-3">
<p className="mb-1 text-[13px] font-semibold">{t("manualToggle")}</p>
<p className="mt-1 mb-3 text-[11.5px] text-muted-foreground">{t("manualHint")}</p>
<AssetForm
action={createPrimaryInformationAsset.bind(null, process.id)}
users={users}
cancelHref={stepHref(1)}
defaultType="INFORMATION"
/>
</div>
) : (
<Button variant="outline" size="sm" nativeButton={false} render={<Link href={formHref(1, "info")} />}>
<Plus className="size-4" /> {t("manualToggle")}
</Button>
)}
</>
)}
</section>
)}
{/* ── Schritt 2: Sekundäre Assets / Träger ────────────────────────── */}
{current === 2 && (
<section className="space-y-4">
<div>
<p className="font-heading text-[15px] font-semibold">{t("s2Title")}</p>
<p className="mt-0.5 text-[12.5px] text-muted-foreground">{t("s2Hint")}</p>
</div>
{suggestedCarrierTypes.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5 text-[12px]">
<span className="font-medium text-muted-foreground">{t("suggestedCarriers")}:</span>
{suggestedCarrierTypes.map((tp) => (
<Pill key={tp} tone="mut">{assetTypeLabel(tp)}</Pill>
))}
</div>
)}
<div>
<p className="mb-2 text-[13px] font-semibold">{t("currentCarriers")}</p>
{secondaries.length === 0 ? (
<p className="text-[12.5px] text-muted-foreground">{t("noCarriers")}</p>
) : (
<div className="flex flex-wrap gap-2">
{secondaries.map((pa) => (
<span key={pa.id} className="inline-flex items-center gap-2 rounded-full border bg-card py-1 pr-2 pl-3 text-sm">
<span className="font-medium">{pa.asset.name}</span>
<Tag>{tType(pa.asset.type)}</Tag>
<CiaBadge c={pa.asset.confidentiality} i={pa.asset.integrity} a={pa.asset.availability} />
{canWrite && (
<form action={unassignAsset.bind(null, process.id, pa.id)} className="flex">
<button type="submit" title={tc("remove")} className="text-muted-foreground hover:text-destructive">
<X className="size-3.5" />
</button>
</form>
)}
</span>
))}
</div>
)}
</div>
{canWrite && (
<div className="space-y-3">
{/* (a) Bestehendes Asset als Träger zuordnen. */}
{availableAssets.length > 0 ? (
<form action={assignAsset.bind(null, process.id)} className="flex flex-wrap gap-2 rounded-xl border bg-card p-3">
<input type="hidden" name="role" value="SECONDARY" />
<select name="assetId" required className={`${selectClass} flex-1`}>
{availableAssets.map((a) => (
<option key={a.id} value={a.id}>
{a.name} · {tType(a.type)}
</option>
))}
</select>
<Button type="submit" variant="secondary" size="sm">
<Plus className="size-4" /> {t("assignCarrier")}
</Button>
</form>
) : (
<p className="text-[12.5px] text-muted-foreground">{t("noAvailable")}</p>
)}
{/* (b) Task 3: NEUES Träger-Asset über die volle Asset-Inventar-Maske anlegen
(SECONDARY). Teil-Dialog via `biaNew=carrier`; Abbrechen schließt ihn. */}
{newForm === "carrier" ? (
<div className="rounded-xl border bg-card p-3">
<p className="mb-1 text-[13px] font-semibold">{t("newCarrier")}</p>
<p className="mt-1 mb-3 text-[11.5px] text-muted-foreground">{t("newCarrierHint")}</p>
<AssetForm
action={createSecondaryCarrierAsset.bind(null, process.id)}
users={users}
cancelHref={stepHref(2)}
defaultType="SYSTEM"
/>
</div>
) : (
<Button variant="outline" size="sm" nativeButton={false} render={<Link href={formHref(2, "carrier")} />}>
<Plus className="size-4" /> {t("newCarrier")}
</Button>
)}
</div>
)}
</section>
)}
{/* ── Schritt 3: Schutzbedarf C/I/A ───────────────────────────────── */}
{current === 3 && (
<section className="space-y-4">
<div>
<p className="font-heading text-[15px] font-semibold">{t("s3Title")}</p>
<p className="mt-0.5 text-[12.5px] text-muted-foreground">{t("s3Hint")}</p>
</div>
{primaries.length === 0 ? (
<p className="text-[12.5px] text-muted-foreground">{t("noPrimaryForCia")}</p>
) : (
primaries.map((pa) => (
<form
key={pa.id}
action={saveInfoProtection.bind(null, pa.asset.id)}
className="rounded-xl border bg-card p-4"
>
<div className="mb-3 flex items-center justify-between gap-2">
<b className="text-sm">{pa.asset.name}</b>
<Tag>{tType(pa.asset.type)}</Tag>
</div>
<div className="grid grid-cols-3 gap-4">
{(
[
["confidentiality", tAssets("confidentiality"), pa.asset.confidentiality],
["integrity", tAssets("integrity"), pa.asset.integrity],
["availability", tAssets("availability"), pa.asset.availability],
] as const
).map(([name, label, value]) => (
<div key={name}>
<Label>{label}</Label>
<div className="mt-1">
<SegmentedRating name={name} defaultValue={value} low={tLevel("1")} high={tLevel("4")} />
</div>
</div>
))}
</div>
{canWrite && (
<div className="mt-3">
<Button type="submit" size="sm">{tc("save")}</Button>
</div>
)}
</form>
))
)}
{secondaries.length > 0 && (
<div className="rounded-xl border border-[var(--band-brd)] bg-[var(--band)] p-3 text-[12.5px] text-[var(--band-text)]">
<span className="inline-flex items-center gap-2">
{t("inheritedMax")}: <CiaBadge c={maxC} i={maxI} a={maxA} labels />
</span>
<p className="mt-1">{t("inheritedHint")}</p>
</div>
)}
</section>
)}
{/* ── Schritt 4: Risiken inkl. Bewertung ──────────────────────────── */}
{current === 4 && (
<section className="space-y-4">
<div>
<p className="font-heading text-[15px] font-semibold">{t("s4Title")}</p>
<p className="mt-0.5 text-[12.5px] text-muted-foreground">{t("s4Hint")}</p>
</div>
{suggestedRisks.length > 0 && (
<div className="rounded-xl border bg-card p-3">
<p className="mb-2 text-[13px] font-semibold">{t("suggestedRisks")}</p>
<ul className="space-y-2">
{suggestedRisks.map((r) => (
<li key={r.code} className="flex items-center gap-2 border-b pb-2 text-[12.5px] last:border-0">
<Pill tone="mut">{r.category}</Pill>
<span className="flex-1">
<span className="font-medium">{r.title}</span>
<span className="ml-1.5 text-[10.5px] text-muted-foreground">{r.code}</span>
</span>
{r.adopted ? (
<Pill tone="ok">{t("adopted")}</Pill>
) : canWrite ? (
<form action={adoptProcessRisk.bind(null, process.id, r.code)}>
<Button type="submit" size="sm" variant="outline">{t("adopt")}</Button>
</form>
) : null}
</li>
))}
</ul>
</div>
)}
<div>
<p className="mb-2 text-[13px] font-semibold">{t("currentRisks")}</p>
{linkedRisks.length === 0 ? (
<p className="text-[12.5px] text-muted-foreground">{t("noRisks")}</p>
) : (
<div className="space-y-3">
{linkedRisks.map((r) => (
<form
key={r.id}
action={rateProcessRisk.bind(null, process.id, r.id)}
className="rounded-xl border bg-card p-3"
>
<div className="mb-2 flex flex-wrap items-center gap-2">
<b className="text-[13px]">{riskRef(r.refNo)}</b>
<span className="flex-1 text-[13px]">{r.title}</span>
<Pill tone={RISK_PILL_TONE[riskLevel(r.score)]}>
{r.score} · {tRiskLevel(riskLevel(r.score))}
</Pill>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div>
<Label htmlFor={`l-${r.id}`}>{t("likelihood")}</Label>
<select id={`l-${r.id}`} name="likelihood" defaultValue={r.likelihood} className={`${selectClass} mt-1 w-full`}>
{SCALE5.map((v) => <option key={v} value={v}>{v}</option>)}
</select>
</div>
<div>
<Label htmlFor={`i-${r.id}`}>{t("impact")}</Label>
<select id={`i-${r.id}`} name="impact" defaultValue={r.impact} className={`${selectClass} mt-1 w-full`}>
{SCALE5.map((v) => <option key={v} value={v}>{v}</option>)}
</select>
</div>
<div>
<Label htmlFor={`t-${r.id}`}>{t("treatment")}</Label>
<select id={`t-${r.id}`} name="treatment" defaultValue={r.treatment} className={`${selectClass} mt-1 w-full`}>
{TREATMENTS.map((v) => <option key={v} value={v}>{tTreat(v)}</option>)}
</select>
</div>
<div>
<Label htmlFor={`s-${r.id}`}>{t("statusField")}</Label>
<select id={`s-${r.id}`} name="status" defaultValue={r.status} className={`${selectClass} mt-1 w-full`}>
{STATUSES.map((v) => <option key={v} value={v}>{tStatus(v)}</option>)}
</select>
</div>
</div>
{canWrite && (
<div className="mt-3 flex items-center gap-2">
<Button type="submit" size="sm">{t("rate")}</Button>
<Link href={`/risks?detail=${r.id}`} className="text-[12px] font-semibold text-[var(--info)] hover:underline">
{t("openInRiskModule")} →
</Link>
</div>
)}
</form>
))}
</div>
)}
</div>
{canWrite && (
<div className="space-y-3">
{/* (a) Task 4a: bestehendes Risiko diesem Prozess zuordnen. */}
{availableRisks.length > 0 && (
<form action={linkProcessRisk.bind(null, process.id)} className="flex flex-wrap gap-2 rounded-xl border bg-card p-3">
<select name="riskId" required className={`${selectClass} flex-1`}>
{availableRisks.map((r) => (
<option key={r.id} value={r.id}>
{riskRef(r.refNo)} · {r.title}
</option>
))}
</select>
<Button type="submit" variant="secondary" size="sm">
<Plus className="size-4" /> {t("linkRisk")}
</Button>
</form>
)}
{/* (b) Task 4b: NEUES Risiko anlegen inkl. Bewertung (Eintritt × Auswirkung).
Teil-Dialog via `biaNew=risk`; Abbrechen schließt ihn (Task 2). */}
{newForm === "risk" ? (
<form action={createProcessRisk.bind(null, process.id)} className="space-y-3 rounded-xl border bg-card p-3">
<p className="text-[13px] font-semibold">{t("newRisk")}</p>
<div>
<Label htmlFor="new-risk-title">{t("riskTitle")}</Label>
<Input id="new-risk-title" name="title" required className="mt-1" />
</div>
<div>
<Label htmlFor="new-risk-desc">{t("riskDescription")}</Label>
<Textarea id="new-risk-desc" name="description" rows={2} className="mt-1" />
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div>
<Label htmlFor="new-risk-l">{t("likelihood")}</Label>
<select id="new-risk-l" name="likelihood" defaultValue={3} className={`${selectClass} mt-1 w-full`}>
{SCALE5.map((v) => <option key={v} value={v}>{v}</option>)}
</select>
</div>
<div>
<Label htmlFor="new-risk-i">{t("impact")}</Label>
<select id="new-risk-i" name="impact" defaultValue={3} className={`${selectClass} mt-1 w-full`}>
{SCALE5.map((v) => <option key={v} value={v}>{v}</option>)}
</select>
</div>
<div>
<Label htmlFor="new-risk-t">{t("treatment")}</Label>
<select id="new-risk-t" name="treatment" defaultValue="MITIGATE" className={`${selectClass} mt-1 w-full`}>
{TREATMENTS.map((v) => <option key={v} value={v}>{tTreat(v)}</option>)}
</select>
</div>
<div>
<Label htmlFor="new-risk-s">{t("statusField")}</Label>
<select id="new-risk-s" name="status" defaultValue="OPEN" className={`${selectClass} mt-1 w-full`}>
{STATUSES.map((v) => <option key={v} value={v}>{tStatus(v)}</option>)}
</select>
</div>
</div>
<div className="flex gap-2">
<Button type="submit" size="sm">{tc("save")}</Button>
<Button variant="outline" size="sm" nativeButton={false} render={<Link href={stepHref(4)} />}>
{tc("cancel")}
</Button>
</div>
</form>
) : (
<Button variant="outline" size="sm" nativeButton={false} render={<Link href={formHref(4, "risk")} />}>
<Plus className="size-4" /> {t("newRisk")}
</Button>
)}
</div>
)}
</section>
)}
{/* ── Schritt 5: Abschluss-Übersicht ──────────────────────────────── */}
{current === 5 && (
<section className="space-y-4">
<div>
<p className="font-heading text-[15px] font-semibold">{t("s5Title")}</p>
<p className="mt-0.5 text-[12.5px] text-muted-foreground">{t("s5Hint")}</p>
</div>
{/* Checkliste */}
<div className="grid gap-2 sm:grid-cols-4">
{(
[
[t("clInfo"), hasInfo],
[t("clCarrier"), hasCarrier],
[t("clCia"), hasCia],
[t("clRisk"), hasRisk],
] as const
).map(([label, ok]) => (
<div key={label} className="rounded-lg border bg-[var(--surface-soft)] p-3">
<Pill tone={ok ? "ok" : "warn"}>{ok ? <Check className="size-3" /> : "•"} {ok ? t("captured") : t("open")}</Pill>
<p className="mt-1.5 text-[12px]">{label}</p>
</div>
))}
</div>
{/* Review: Informationswerte + CIA */}
<div className="rounded-xl border bg-card p-4">
<p className="mb-2 text-[13px] font-semibold">{t("reviewInfo")}</p>
{primaries.length === 0 ? (
<p className="text-[12.5px] text-muted-foreground">{t("noPrimaries")}</p>
) : (
<ul className="space-y-1.5 text-sm">
{primaries.map((pa) => (
<li key={pa.id} className="flex flex-wrap items-center gap-2 border-b pb-1.5 last:border-0">
<b className="flex-1">{pa.asset.name}</b>
{pa.asset.label !== "NONE" && <Pill tone="warn">{infoLabelLabel(pa.asset.label)}</Pill>}
<CiaBadge c={pa.asset.confidentiality} i={pa.asset.integrity} a={pa.asset.availability} />
</li>
))}
</ul>
)}
</div>
{/* Review: Träger */}
<div className="rounded-xl border bg-card p-4">
<p className="mb-2 text-[13px] font-semibold">{t("reviewCarriers")}</p>
{secondaries.length === 0 ? (
<p className="text-[12.5px] text-muted-foreground">{t("noCarriers")}</p>
) : (
<div className="flex flex-wrap items-center gap-2 text-sm">
<CiaLegend />
{secondaries.map((pa) => (
<span key={pa.id} className="inline-flex items-center gap-2 rounded-full border px-3 py-1">
{pa.asset.name}
<CiaBadge c={pa.asset.confidentiality} i={pa.asset.integrity} a={pa.asset.availability} />
</span>
))}
</div>
)}
</div>
{/* Review: Risiken */}
<div className="rounded-xl border bg-card p-4">
<p className="mb-2 text-[13px] font-semibold">{t("reviewRisks")}</p>
{linkedRisks.length === 0 ? (
<p className="text-[12.5px] text-muted-foreground">{t("noRisks")}</p>
) : (
<ul className="space-y-1.5 text-sm">
{linkedRisks.map((r) => (
<li key={r.id} className="flex flex-wrap items-center gap-2 border-b pb-1.5 last:border-0">
<b>{riskRef(r.refNo)}</b>
<span className="flex-1">{r.title}</span>
<Tag>{tTreat(r.treatment)}</Tag>
<Pill tone={RISK_PILL_TONE[riskLevel(r.score)]}>
{r.score} · {tRiskLevel(riskLevel(r.score))}
</Pill>
</li>
))}
</ul>
)}
</div>
{/* Abschluss → biaStatus setzen (Prozesshaus-Farbe) */}
{canWrite && (
<div className="rounded-xl border border-[var(--band-brd)] bg-[var(--band)] p-4">
<p className="text-[13px] font-semibold text-[var(--band-text)]">{t("finishTitle")}</p>
<p className="mt-0.5 text-[12px] text-[var(--band-text)]">
{t("finishHint", { done: doneCount, total: 4 })}
</p>
<div className="mt-3 flex flex-wrap gap-2">
<form action={setBiaStatus.bind(null, process.id)}>
<input type="hidden" name="biaStatus" value="komplett" />
<Button type="submit">
<Check className="size-4" /> {t("markComplete")}
</Button>
</form>
<form action={setBiaStatus.bind(null, process.id)}>
<input type="hidden" name="biaStatus" value="teilweise" />
<Button type="submit" variant="outline">{t("markPartial")}</Button>
</form>
{process.bia && (
<span className="ml-auto inline-flex items-center gap-2 text-[12.5px] text-[var(--band-text)]">
{t("criticality")}:{" "}
<CriticalityPill level={process.bia.criticality} label={String(process.bia.criticality)} />
</span>
)}
</div>
</div>
)}
</section>
)}
</div>
</Modal>
);
}
/** Farbton der biaStatus-Pille (auch im Prozesshaus verwendet). */
export const BIA_TONE: Record<string, "mut" | "warn" | "ok"> = {
offen: "mut",
teilweise: "warn",
komplett: "ok",
};
-344
View File
@@ -1,344 +0,0 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
ReactFlow,
ReactFlowProvider,
Background,
BackgroundVariant,
Controls,
Handle,
Position,
useReactFlow,
type Edge,
type Node,
type NodeProps,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import dagre from "@dagrejs/dagre";
import { toPng } from "html-to-image";
import {
Boxes,
Database,
AppWindow,
MapPin,
Truck,
Server,
Package,
FolderKanban,
Users,
FileText,
GitBranch,
Crosshair,
Search,
Download,
Zap,
ZapOff,
GitBranch as GitBranchIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { UI_COLORS } from "@/lib/brand";
import type { DependencyGraph, GraphNode, GraphNodeKind } from "@/server/dependency-graph";
const KIND_ICON: Record<GraphNodeKind, typeof Boxes> = {
process: GitBranch,
INFORMATION: FileText,
SYSTEM: Boxes,
APPLICATION: AppWindow,
LOCATION: MapPin,
SUPPLIER: Truck,
IT_SERVICE: Server,
SOFTWARE: Package,
PROJECT: FolderKanban,
PERSON: Users,
DATA: Database,
};
type NodeData = GraphNode & { dimmed: boolean; kindLabel: string };
/** Custom-Node im Topology-Look (Referenz ISMS-Abhaengigkeitskarte). */
function IsmsNode({ data }: NodeProps<Node<NodeData>>) {
const Icon = KIND_ICON[data.kind] ?? Boxes;
const critColor =
data.criticality >= 4
? "var(--risk)"
: data.criticality >= 3
? "var(--warn)"
: "var(--ok)";
return (
<div
className={cn(
"flex w-49 items-center gap-2.5 rounded-2xl border px-3 py-2.5 transition-all",
"bg-[var(--panel)] backdrop-blur",
data.critical
? "border-[rgba(255,107,107,0.5)] shadow-[0_0_0_1px_rgba(255,107,107,0.25),0_8px_26px_rgba(255,80,80,0.12)]"
: "border-[var(--panel-brd)]",
data.dimmed && "opacity-25"
)}
>
<Handle type="target" position={Position.Left} className="!bg-[var(--muted-foreground)]" />
<span
className={cn(
"grid size-9 shrink-0 place-items-center rounded-xl",
data.critical
? "bg-[rgba(255,107,107,0.16)] text-[var(--risk)]"
: "bg-[var(--ui-primary-soft)] text-[var(--ui-blue)]"
)}
>
<Icon className="size-4.5" />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate text-[13px] font-semibold text-[var(--txt)]">
{data.name}
</span>
</div>
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
<span className="inline-block size-2 rounded-full" style={{ background: critColor }} />
{data.kindLabel} · K{data.criticality}
{data.spof && (
<span className="ml-0.5 rounded bg-[rgba(255,107,107,0.18)] px-1.5 py-px text-[9.5px] font-bold text-[var(--risk)]">
SPOF
</span>
)}
</div>
</div>
<Handle type="source" position={Position.Right} className="!bg-[var(--muted-foreground)]" />
</div>
);
}
const nodeTypes = { isms: IsmsNode };
function layout(graph: DependencyGraph, kindLabels: Record<string, string>) {
const g = new dagre.graphlib.Graph();
g.setDefaultEdgeLabel(() => ({}));
g.setGraph({ rankdir: "LR", nodesep: 34, ranksep: 130, marginx: 20, marginy: 20 });
graph.nodes.forEach((n) => g.setNode(n.id, { width: 200, height: 64 }));
graph.edges.forEach((e) => g.setEdge(e.source, e.target));
dagre.layout(g);
const nodes: Node<NodeData>[] = graph.nodes.map((n) => {
const p = g.node(n.id);
return {
id: n.id,
type: "isms",
position: { x: p.x - 100, y: p.y - 32 },
data: { ...n, dimmed: false, kindLabel: kindLabels[n.kind] ?? n.kind },
};
});
return nodes;
}
function Canvas({
graph,
kindLabels,
labels,
}: {
graph: DependencyGraph;
kindLabels: Record<string, string>;
labels: Record<string, string>;
}) {
const rf = useReactFlow();
const [showCritical, setShowCritical] = useState(true);
const [onlyProcesses, setOnlyProcesses] = useState(false);
const [focus, setFocus] = useState<string | null>(null);
const [query, setQuery] = useState("");
// Optionaler Filter „nur Prozesse": reduziert die Karte auf Prozesse und ihre
// Prozess-zu-Prozess-Abhängigkeiten (blendet Assets/Träger aus).
const view = useMemo(() => {
if (!onlyProcesses) return graph;
const procIds = new Set(graph.nodes.filter((n) => n.entity === "process").map((n) => n.id));
return {
...graph,
nodes: graph.nodes.filter((n) => procIds.has(n.id)),
edges: graph.edges.filter((e) => procIds.has(e.source) && procIds.has(e.target)),
};
}, [graph, onlyProcesses]);
const baseNodes = useMemo(() => layout(view, kindLabels), [view, kindLabels]);
// Nach Filterwechsel neu einpassen.
useEffect(() => {
const id = setTimeout(() => rf.fitView({ duration: 400, padding: 0.15 }), 60);
return () => clearTimeout(id);
}, [onlyProcesses, rf]);
// Zusammenhang für Fokus-Highlight (ungerichtet)
const neighbors = useMemo(() => {
const m = new Map<string, Set<string>>();
for (const e of view.edges) {
if (!m.has(e.source)) m.set(e.source, new Set());
if (!m.has(e.target)) m.set(e.target, new Set());
m.get(e.source)!.add(e.target);
m.get(e.target)!.add(e.source);
}
return m;
}, [view.edges]);
const focusSet = useMemo(() => {
if (!focus) return null;
const seen = new Set<string>([focus]);
const stack = [focus];
while (stack.length) {
const cur = stack.pop()!;
for (const nb of neighbors.get(cur) ?? []) {
if (!seen.has(nb)) {
seen.add(nb);
stack.push(nb);
}
}
}
return seen;
}, [focus, neighbors]);
const nodes: Node<NodeData>[] = useMemo(
() =>
baseNodes.map((n) => ({
...n,
data: { ...n.data, dimmed: focusSet ? !focusSet.has(n.id) : false },
})),
[baseNodes, focusSet]
);
const edges: Edge[] = useMemo(
() =>
view.edges.map((e) => {
const isCrit = e.critical && showCritical;
const inFocus = !focusSet || (focusSet.has(e.source) && focusSet.has(e.target));
return {
id: e.id,
source: e.source,
target: e.target,
label: e.label,
type: "smoothstep",
animated: isCrit,
className: isCrit ? "critical-edge" : undefined,
labelStyle: { fill: "var(--txt-muted)", fontSize: 10, fontWeight: 600 },
labelBgStyle: { fill: "var(--bg-1)" },
style: {
stroke: isCrit ? "var(--risk)" : "var(--graph-edge)",
strokeWidth: isCrit ? 2.5 : 1.5,
opacity: inFocus ? 1 : 0.12,
},
};
}),
[view.edges, showCritical, focusSet]
);
const onNodeClick = useCallback((_: unknown, node: Node) => setFocus(node.id), []);
const onPaneClick = useCallback(() => setFocus(null), []);
const runSearch = useCallback(
(q: string) => {
setQuery(q);
if (!q.trim()) return;
const hit = baseNodes.find((n) => n.data.name.toLowerCase().includes(q.toLowerCase()));
if (hit) {
setFocus(hit.id);
rf.setCenter(hit.position.x + 100, hit.position.y + 32, { zoom: 1.2, duration: 500 });
}
},
[baseNodes, rf]
);
const exportPng = useCallback(async () => {
const el = document.querySelector<HTMLElement>(".react-flow__viewport");
if (!el) return;
const dataUrl = await toPng(el, {
// html-to-image rendert außerhalb des CSS-Kontexts der Seite — CSS-Variablen
// sind hier nicht auflösbar, daher der Literal-Wert aus src/lib/brand.ts.
backgroundColor: UI_COLORS.bg0,
filter: (node) =>
!(node instanceof HTMLElement && node.classList?.contains("react-flow__controls")),
});
const a = document.createElement("a");
a.href = dataUrl;
a.download = "abhaengigkeiten.png";
a.click();
}, []);
return (
<div className="relative h-[600px] overflow-hidden rounded-2xl border border-[var(--panel-brd)] bg-[var(--bg-1)]">
{/* Toolbar */}
<div className="absolute top-3 left-3 z-10 flex flex-wrap items-center gap-2">
<div className="flex items-center gap-2 rounded-lg border border-[var(--panel-brd)] bg-[var(--panel)] px-3 py-1.5 backdrop-blur">
<Search className="size-4 text-muted-foreground" />
<input
value={query}
onChange={(e) => runSearch(e.target.value)}
placeholder={labels.search}
className="w-40 border-0 bg-transparent text-[13px] text-foreground outline-none placeholder:text-muted-foreground"
/>
</div>
<button
onClick={() => setShowCritical((v) => !v)}
className={cn(
"inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-[13px] font-semibold backdrop-blur",
showCritical
? "border-[rgba(255,107,107,0.4)] bg-[rgba(255,107,107,0.12)] text-[#ff6b6b]"
: "border-[var(--panel-brd)] bg-[var(--panel)] text-muted-foreground"
)}
>
{showCritical ? <Zap className="size-4" /> : <ZapOff className="size-4" />}
{labels.criticalToggle}
</button>
<button
onClick={() => setOnlyProcesses((v) => !v)}
title={labels.onlyProcesses}
className={cn(
"inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-[13px] font-semibold backdrop-blur",
onlyProcesses
? "border-[var(--ui-primary)] bg-[var(--ui-primary-soft)] text-[var(--ui-blue)]"
: "border-[var(--panel-brd)] bg-[var(--panel)] text-muted-foreground"
)}
>
<GitBranchIcon className="size-4" />
{labels.onlyProcesses}
</button>
<button
onClick={() => rf.fitView({ duration: 500, padding: 0.15 })}
title={labels.fit}
className="inline-flex items-center gap-1.5 rounded-lg border border-[var(--panel-brd)] bg-[var(--panel)] px-3 py-1.5 text-[13px] font-semibold text-muted-foreground backdrop-blur hover:text-foreground"
>
<Crosshair className="size-4" /> {labels.fit}
</button>
<button
onClick={exportPng}
title="PNG"
className="inline-flex items-center gap-1.5 rounded-lg border border-[var(--panel-brd)] bg-[var(--panel)] px-3 py-1.5 text-[13px] font-semibold text-muted-foreground backdrop-blur hover:text-foreground"
>
<Download className="size-4" /> PNG
</button>
</div>
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
onNodeClick={onNodeClick}
onPaneClick={onPaneClick}
fitView
fitViewOptions={{ padding: 0.15 }}
minZoom={0.2}
proOptions={{ hideAttribution: true }}
>
<Background variant={BackgroundVariant.Dots} gap={26} size={1} color="rgba(140,155,200,0.18)" />
<Controls className="!border-[var(--panel-brd)] !bg-[var(--panel)]" showInteractive={false} />
</ReactFlow>
</div>
);
}
export function DependencyGraphView(props: {
graph: DependencyGraph;
kindLabels: Record<string, string>;
labels: Record<string, string>;
}) {
return (
<ReactFlowProvider>
<Canvas {...props} />
</ReactFlowProvider>
);
}
-28
View File
@@ -1,28 +0,0 @@
import Link from "next/link";
import { cn } from "@/lib/utils";
/** Pillen-Tabs wie im Mockup (Filter per Link/searchParams, serverseitig). */
export function FilterTabs({
tabs,
}: {
tabs: { href: string; label: string; active: boolean }[];
}) {
return (
<div className="flex flex-wrap gap-1.5">
{tabs.map((tab) => (
<Link
key={tab.href}
href={tab.href}
className={cn(
"rounded-full border px-3.5 py-[7px] font-heading text-[12.5px] font-semibold transition-colors",
tab.active
? "border-transparent bg-[var(--sidebar-accent)] text-[var(--primary)]"
: "border-border bg-card text-muted-foreground hover:text-foreground"
)}
>
{tab.label}
</Link>
))}
</div>
);
}
-88
View File
@@ -1,88 +0,0 @@
import { Plus, Trash2 } from "lucide-react";
import { addRegisterRow, updateRegisterRow, deleteRegisterRow } from "@/server/actions/register";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
/**
* Generischer Editor für verwaltete Register (WP3.0): rendert die Pflichtspalten
* als editierbare Zeilen inkl. optionaler Cross-Links (Lieferant/Asset).
*/
export interface RegisterColumn { key: string; label: string }
export interface RegisterInfo {
code: string; title: string; description: string | null;
columns: RegisterColumn[]; supplierLink: boolean; assetLink: boolean;
}
export interface RegisterRowData { id: string; values: Record<string, string>; supplierRef: string | null; assetRef: string | null }
export interface AssetOption { id: string; name: string }
const cell = "h-8 text-[12.5px]";
function RefSelect({ name, value, options, label }: { name: string; value: string | null; options: AssetOption[]; label: string }) {
return (
<div>
<label className="block text-[11px] text-muted-foreground">{label}</label>
<select name={name} defaultValue={value ?? ""} className="mt-0.5 h-8 w-full rounded-md border border-input bg-transparent px-2 text-[12.5px]">
<option value="">—</option>
{options.map((o) => <option key={o.id} value={o.id}>{o.name}</option>)}
</select>
</div>
);
}
function Fields({ register, row, suppliers, assets, disabled }: {
register: RegisterInfo; row?: RegisterRowData; suppliers: AssetOption[]; assets: AssetOption[]; disabled?: boolean;
}) {
return (
<>
{register.columns.map((c) => (
<div key={c.key}>
<label className="block text-[11px] text-muted-foreground">{c.label}</label>
<Input name={`col_${c.key}`} defaultValue={row?.values?.[c.key] ?? ""} disabled={disabled} className={`mt-0.5 ${cell}`} />
</div>
))}
{register.supplierLink && <RefSelect name="supplierRef" value={row?.supplierRef ?? null} options={suppliers} label="Lieferant" />}
{register.assetLink && <RefSelect name="assetRef" value={row?.assetRef ?? null} options={assets} label="Asset" />}
</>
);
}
export function GenericRegisterView({ register, rows, canWrite, suppliers, assets }: {
register: RegisterInfo; rows: RegisterRowData[]; canWrite: boolean; suppliers: AssetOption[]; assets: AssetOption[];
}) {
const gridCols = register.columns.length + (register.supplierLink ? 1 : 0) + (register.assetLink ? 1 : 0);
const gridStyle = { gridTemplateColumns: `repeat(${gridCols}, minmax(0, 1fr))` };
return (
<div className="space-y-3">
{register.description && <p className="text-[13px] text-muted-foreground">{register.description}</p>}
<div className="space-y-2">
{rows.map((r) => (
<div key={r.id} className="rounded-xl border bg-card p-3">
<div className="flex flex-wrap items-end gap-2">
<form action={updateRegisterRow.bind(null, r.id)} className="grid flex-1 gap-2" style={gridStyle}>
<Fields register={register} row={r} suppliers={suppliers} assets={assets} disabled={!canWrite} />
{canWrite && <div className="col-span-full"><Button type="submit" variant="outline" size="sm">Speichern</Button></div>}
</form>
{canWrite && (
<form action={deleteRegisterRow.bind(null, r.id)}>
<Button type="submit" variant="ghost" size="sm" title="Löschen"><Trash2 className="size-4" /></Button>
</form>
)}
</div>
</div>
))}
{rows.length === 0 && <p className="text-sm text-muted-foreground">Noch keine Einträge in diesem Register.</p>}
</div>
{canWrite && (
<form action={addRegisterRow.bind(null, register.code)} className="rounded-xl border border-dashed bg-card p-3">
<p className="mb-2 font-heading text-[12.5px] font-semibold">Eintrag hinzufügen</p>
<div className="grid gap-2" style={gridStyle}>
<Fields register={register} suppliers={suppliers} assets={assets} />
</div>
<Button type="submit" size="sm" className="mt-3"><Plus className="size-4" /> Hinzufügen</Button>
</form>
)}
</div>
);
}
-114
View File
@@ -1,114 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { Pill } from "@/components/mockup-ui";
/**
* Live-Countdown der Vorfall-Fristen (IM-B, §6). Rendert die verbleibende Zeit je
* Frist und hebt überfällige hervor. Rein clientseitig: die Fristen kommen als
* ISO-Strings vom Server, der Tick aktualisiert nur die Anzeige.
*/
export type DeadlineView = {
kind: string;
label: string;
dueAt: string; // ISO
done: boolean;
meldung: boolean;
};
export type DeadlineLabels = {
meldungHead: string;
slaHead: string;
remaining: string;
overdue: string;
submitted: string;
none: string;
};
function fmtDuration(ms: number): string {
const abs = Math.abs(ms);
const d = Math.floor(abs / 86_400_000);
const h = Math.floor((abs % 86_400_000) / 3_600_000);
const m = Math.floor((abs % 3_600_000) / 60_000);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
function fmtDate(iso: string): string {
return new Intl.DateTimeFormat("de-DE", { dateStyle: "medium", timeStyle: "short" }).format(
new Date(iso),
);
}
function DeadlineRow({ item, now, labels }: { item: DeadlineView; now: number; labels: DeadlineLabels }) {
const ms = new Date(item.dueAt).getTime() - now;
const overdue = !item.done && ms < 0;
const soon = !item.done && ms >= 0 && ms <= 24 * 3_600_000;
return (
<li className="flex items-center justify-between gap-2 border-b py-1 text-[12px] last:border-0">
<span className="min-w-0">
<span className="font-medium">{item.label}</span>
<span className="ml-2 text-muted-foreground">{fmtDate(item.dueAt)}</span>
</span>
{item.done ? (
<Pill tone="ok">{labels.submitted}</Pill>
) : overdue ? (
<Pill tone="risk">
{labels.overdue} {fmtDuration(ms)}
</Pill>
) : (
<Pill tone={soon ? "warn" : "info"}>
{labels.remaining} {fmtDuration(ms)}
</Pill>
)}
</li>
);
}
export function IncidentDeadlines({
items,
labels,
}: {
items: DeadlineView[];
labels: DeadlineLabels;
}) {
const [now, setNow] = useState<number>(() => Date.now());
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 30_000);
return () => clearInterval(id);
}, []);
if (items.length === 0) {
return <p className="text-[12px] text-muted-foreground">{labels.none}</p>;
}
const meldung = items.filter((i) => i.meldung);
const sla = items.filter((i) => !i.meldung);
return (
<div className="space-y-3">
{meldung.length > 0 && (
<div>
<p className="mb-1 text-[12px] font-semibold">{labels.meldungHead}</p>
<ul>
{meldung.map((i) => (
<DeadlineRow key={i.kind} item={i} now={now} labels={labels} />
))}
</ul>
</div>
)}
{sla.length > 0 && (
<div>
<p className="mb-1 text-[12px] font-semibold">{labels.slaHead}</p>
<ul>
{sla.map((i) => (
<DeadlineRow key={i.kind} item={i} now={now} labels={labels} />
))}
</ul>
</div>
)}
</div>
);
}
-765
View File
@@ -1,765 +0,0 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Lock, Pencil, Plus, Trash2, X } from "lucide-react";
import type { Prisma } from "@prisma/client";
import {
addIncidentComment,
advanceReportStatus,
createEvidenceForIncident,
createIncident,
createMeasureForIncident,
createRiskForIncident,
linkIncidentAsset,
linkIncidentControl,
linkIncidentEvidence,
linkIncidentMeasure,
linkIncidentProcess,
linkIncidentRisk,
setIncidentOwners,
setIncidentReportability,
setIncidentRestricted,
setIncidentReview,
transitionIncident,
unlinkIncidentAsset,
unlinkIncidentControl,
unlinkIncidentEvidence,
unlinkIncidentMeasure,
unlinkIncidentProcess,
unlinkIncidentRisk,
updateIncident,
} from "@/server/actions/incidents";
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, Tag } from "@/components/mockup-ui";
import { measureRef } from "@/lib/measure";
import { riskRef } from "@/lib/risk";
import {
INCIDENT_CATEGORIES,
INCIDENT_PRIORITIES,
INCIDENT_SOURCES,
REPORT_STATUS_TONE,
SEVERITY_TONE,
STATUS_TONE,
STATUS_TRANSITIONS,
TRANSITION_REQUIRES,
type IncidentStatus,
} from "@/lib/incident";
import {
deadlineItems,
nextReportStatus,
type ReportStatus,
} from "@/lib/incident-deadlines";
import { IncidentDeadlines, type DeadlineView } from "@/components/incident-deadlines";
export const INCIDENT_INCLUDE = {
owner: { select: { id: true, name: true } },
assignee: { select: { id: true, name: true } },
incidentAssets: { include: { asset: { select: { id: true, name: true } } } },
incidentProcesses: { include: { process: { select: { id: true, name: true } } } },
incidentRisks: { include: { risk: { select: { id: true, refNo: true, title: true } } } },
incidentControls: true,
incidentMeasures: {
include: {
measure: {
select: { id: true, refNo: true, title: true, status: true, dueDate: true, owner: { select: { name: true } } },
},
},
},
incidentEvidence: { include: { evidence: { select: { id: true, title: true, kind: true } } } },
comments: { orderBy: { createdAt: "asc" } },
} as const;
export type IncidentWithDetail = Prisma.IncidentGetPayload<{ include: typeof INCIDENT_INCLUDE }>;
const selectClass = "h-9 rounded-md border border-input bg-transparent px-3 text-sm";
const SCALE04 = [0, 1, 2, 3, 4] as const;
/** Kurzlabel für den Maßnahmen-Status (aus dem zentralen Maßnahmen-Modul). */
const MEASURE_STATUS_LABEL: Record<string, string> = {
OPEN: "offen",
IN_PROGRESS: "in Arbeit",
DONE: "erledigt",
};
const tMeasureStatus = (s: string) => MEASURE_STATUS_LABEL[s] ?? s;
function fmtDate(d: Date | null): string {
if (!d) return "—";
return new Intl.DateTimeFormat("de-DE", { dateStyle: "medium", timeStyle: "short" }).format(d);
}
function toDatetimeLocal(d: Date | null): string {
if (!d) return "";
const off = d.getTimezoneOffset();
return new Date(d.getTime() - off * 60000).toISOString().slice(0, 16);
}
/** Gemeinsame Basisfelder für Anlegen + Bearbeiten. */
async function IncidentFields({ incident }: { incident?: IncidentWithDetail }) {
const t = await getTranslations("incidents");
const tCat = await getTranslations("incidentCategory");
return (
<>
<div>
<Label htmlFor="title">{t("titleField")}</Label>
<Input id="title" name="title" required defaultValue={incident?.title} className="mt-1" />
</div>
<div>
<Label htmlFor="description">{t("description")}</Label>
<Textarea id="description" name="description" rows={3} defaultValue={incident?.description ?? ""} className="mt-1" />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="category">{t("category")}</Label>
<select id="category" name="category" defaultValue={incident?.category ?? "other"} className={`${selectClass} mt-1 w-full`}>
{INCIDENT_CATEGORIES.map((c) => (
<option key={c} value={c}>{tCat(c)}</option>
))}
</select>
</div>
<div>
<Label htmlFor="source">{t("source")}</Label>
<select id="source" name="source" defaultValue={incident?.source ?? "manual"} className={`${selectClass} mt-1 w-full`}>
{INCIDENT_SOURCES.map((s) => (
<option key={s} value={s}>{t(`source_${s}`)}</option>
))}
</select>
</div>
<div>
<Label htmlFor="reporterName">{t("reporterName")}</Label>
<Input id="reporterName" name="reporterName" defaultValue={incident?.reporterName ?? ""} className="mt-1" />
</div>
<div>
<Label htmlFor="reporterContact">{t("reporterContact")}</Label>
<Input id="reporterContact" name="reporterContact" defaultValue={incident?.reporterContact ?? ""} className="mt-1" />
</div>
<div>
<Label htmlFor="occurredAt">{t("occurredAt")}</Label>
<Input id="occurredAt" name="occurredAt" type="datetime-local" defaultValue={toDatetimeLocal(incident?.occurredAt ?? null)} className="mt-1" />
</div>
<div>
<Label htmlFor="detectedAt">{t("detectedAt")}</Label>
<Input id="detectedAt" name="detectedAt" type="datetime-local" defaultValue={toDatetimeLocal(incident?.detectedAt ?? null)} className="mt-1" />
</div>
</div>
<div className="rounded-lg border bg-[var(--surface-soft)] p-3">
<p className="text-[12px] font-semibold">{t("impactHead")}</p>
<p className="mb-2 text-[11px] text-muted-foreground">{t("impactHint")}</p>
<div className="grid grid-cols-4 gap-3">
{(["impactC", "impactI", "impactA", "urgency"] as const).map((f) => (
<div key={f}>
<Label htmlFor={f}>{t(f)}</Label>
<select id={f} name={f} defaultValue={incident?.[f] ?? (f === "urgency" ? 2 : 0)} className={`${selectClass} mt-1 w-full`}>
{SCALE04.map((v) => (
<option key={v} value={v}>{v}</option>
))}
</select>
</div>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="priority">{t("priority")}</Label>
<select id="priority" name="priority" defaultValue={incident?.priority ?? "mittel"} className={`${selectClass} mt-1 w-full`}>
{INCIDENT_PRIORITIES.map((p) => (
<option key={p} value={p}>{p}</option>
))}
</select>
</div>
<div>
<Label htmlFor="affectedDataCategories">{t("dataCategories")}</Label>
<Input id="affectedDataCategories" name="affectedDataCategories" placeholder={t("dataCategoriesHint")} defaultValue={(incident?.affectedDataCategories ?? []).join(", ")} className="mt-1" />
</div>
</div>
<div className="flex flex-wrap gap-5 text-sm">
<label className="flex items-center gap-2">
<input type="checkbox" name="personalData" defaultChecked={incident?.personalData ?? false} /> {t("personalData")}
</label>
<label className="flex items-center gap-2">
<input type="checkbox" name="prototypeData" defaultChecked={incident?.prototypeData ?? false} /> {t("prototypeData")}
</label>
<label className="flex items-center gap-2">
<input type="checkbox" name="nis2Relevant" defaultChecked={incident?.nis2Relevant ?? false} /> {t("nis2Relevant")}
</label>
</div>
<p className="text-[11px] text-muted-foreground">{t("severityAutoHint")}</p>
<input type="hidden" name="reportedAt" value={toDatetimeLocal(incident?.reportedAt ?? null)} />
</>
);
}
/** Anlegen als Popup. */
export async function IncidentCreateModal() {
const t = await getTranslations("incidents");
const tc = await getTranslations("common");
return (
<Modal title={t("createTitle")} sub={t("createSub")} closeHref="/incidents" closeLabel={t("close")}>
<form action={createIncident} className="space-y-4 p-5">
<IncidentFields />
<div className="flex gap-2 pt-1">
<Button type="submit">{tc("save")}</Button>
<Button variant="outline" nativeButton={false} render={<Link href="/incidents" />}>{tc("cancel")}</Button>
</div>
</form>
</Modal>
);
}
/** Read-only Detail als Popup inkl. Statuswechsel, Timeline, Kommentaren, Verknüpfungen. */
export async function IncidentDetailModal({
incident,
audit,
actorNames,
users,
canManage,
canClose,
canReport,
nis2Category,
}: {
incident: IncidentWithDetail;
audit: { id: string; createdAt: Date; actorId: string | null; action: string; after: unknown }[];
actorNames: Record<string, string>;
users: { id: string; name: string }[];
canManage: boolean;
canClose: boolean;
canReport: boolean;
nis2Category: string;
}) {
const t = await getTranslations("incidents");
const tCat = await getTranslations("incidentCategory");
const tStatus = await getTranslations("incidentStatus");
const tSev = await getTranslations("incidentSeverity");
const tc = await getTranslations("common");
const from = incident.status as IncidentStatus;
const targets = STATUS_TRANSITIONS[from] ?? [];
// Fristen/Countdown (§6) — Werte serverseitig ableiten, live tickt der Client.
const MELDUNG_KINDS = ["erstmeldung", "folgemeldung", "abschluss", "dsgvo"];
const deadlineViews: DeadlineView[] = deadlineItems(incident).map((d) => ({
kind: d.kind,
label: t(`deadlineKind_${d.kind}`),
dueAt: d.dueAt.toISOString(),
done: d.done,
meldung: MELDUNG_KINDS.includes(d.kind),
}));
const reportStatus = incident.reportStatus as ReportStatus;
const nextReport = reportStatus !== "none" ? nextReportStatus(reportStatus) : null;
return (
<Modal
title={`${incident.refNo} · ${incident.title}`}
sub={t("detailSub")}
headerExtra={
<span className="flex items-center gap-2">
{incident.restricted && (
<Pill tone="risk">
<Lock className="mr-1 inline size-3" /> {t("restricted")}
</Pill>
)}
<Pill tone={SEVERITY_TONE[incident.severity as keyof typeof SEVERITY_TONE] ?? "mut"}>{tSev(incident.severity)}</Pill>
<Pill tone={STATUS_TONE[from] ?? "mut"}>{tStatus(from)}</Pill>
</span>
}
closeHref="/incidents"
closeLabel={t("close")}
footer={
<>
{canManage && (
<Button variant="outline" nativeButton={false} render={<Link href={`/incidents?edit=${incident.id}`} />}>
<Pencil className="size-4" /> {tc("edit")}
</Button>
)}
<Button nativeButton={false} render={<Link href="/incidents" />}>{t("close")}</Button>
</>
}
>
<div className="grid gap-5 p-5 md:grid-cols-2">
{/* Stammdaten */}
<div className="rounded-xl border border-l-[3px] border-l-[var(--primary)] bg-[var(--surface-soft)] p-4">
{incident.description && <p className="mb-2 text-[12.5px] leading-relaxed">{incident.description}</p>}
<dl className="grid grid-cols-[9rem_1fr] gap-1.5 text-[12.5px]">
<dt className="text-muted-foreground">{t("category")}</dt>
<dd><Tag>{tCat(incident.category)}</Tag></dd>
<dt className="text-muted-foreground">{t("source")}</dt>
<dd>{t(`source_${incident.source}`)}</dd>
<dt className="text-muted-foreground">{t("reporter")}</dt>
<dd>{incident.reporterName ?? tc("none")}{incident.reporterContact ? ` (${incident.reporterContact})` : ""}</dd>
<dt className="text-muted-foreground">{t("owner")}</dt>
<dd>{incident.owner?.name ?? tc("none")}</dd>
<dt className="text-muted-foreground">{t("assignee")}</dt>
<dd>{incident.assignee?.name ?? tc("none")}</dd>
<dt className="text-muted-foreground">{t("occurredAt")}</dt>
<dd>{fmtDate(incident.occurredAt)}</dd>
<dt className="text-muted-foreground">{t("detectedAt")}</dt>
<dd>{fmtDate(incident.detectedAt)}</dd>
<dt className="text-muted-foreground">{t("impactCia")}</dt>
<dd>C{incident.impactC} · I{incident.impactI} · A{incident.impactA} · {t("urgency")} {incident.urgency}</dd>
<dt className="text-muted-foreground">{t("flags")}</dt>
<dd className="flex flex-wrap gap-1">
{incident.dsgvoRelevant && <Pill tone="warn">DSGVO</Pill>}
{incident.prototypeData && <Pill tone="info">TISAX</Pill>}
{incident.nis2Relevant && <Pill tone="orange">NIS2</Pill>}
{!incident.dsgvoRelevant && !incident.prototypeData && !incident.nis2Relevant && tc("none")}
</dd>
</dl>
</div>
{/* Steuerung: Status + Owner/Assignee + Vertraulichkeit */}
<div className="space-y-4">
{canManage && (
<div className="rounded-xl border bg-card p-4">
<p className="text-[13px] font-semibold">{t("statusChange")}</p>
{targets.length === 0 ? (
<p className="mt-1 text-[11.5px] text-muted-foreground">{t("noTransitions")}</p>
) : (
<form action={transitionIncident.bind(null, incident.id)} className="mt-2 space-y-2">
<select name="status" className={`${selectClass} w-full`} required>
{targets.map((s) => (
<option key={s} value={s} disabled={s === "abgeschlossen" && !canClose}>
{tStatus(s)}{TRANSITION_REQUIRES[s] ? " *" : ""}
</option>
))}
</select>
{/* Pflichtfelder je Abschluss/Behebung (§8) — optional hier setzen, sonst müssen sie bereits existieren. */}
<Textarea name="rootCause" rows={2} placeholder={t("rootCause")} defaultValue={incident.rootCause ?? ""} className="text-[12px]" />
<Textarea name="resolution" rows={2} placeholder={t("resolution")} defaultValue={incident.resolution ?? ""} className="text-[12px]" />
<Textarea name="closingNote" rows={2} placeholder={t("closingNote")} defaultValue={incident.closingNote ?? ""} className="text-[12px]" />
<Textarea name="lessonsLearned" rows={2} placeholder={t("lessonsLearned")} defaultValue={incident.lessonsLearned ?? ""} className="text-[12px]" />
<Button type="submit" size="sm">{t("applyStatus")}</Button>
</form>
)}
</div>
)}
{canManage && (
<form action={setIncidentOwners.bind(null, incident.id)} className="rounded-xl border bg-card p-4 space-y-2">
<p className="text-[13px] font-semibold">{t("steering")}</p>
<div>
<Label htmlFor="ownerId">{t("owner")}</Label>
<select id="ownerId" name="ownerId" defaultValue={incident.ownerId ?? ""} className={`${selectClass} mt-1 w-full`}>
<option value="">{tc("none")}</option>
{users.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
</select>
</div>
<div>
<Label htmlFor="assigneeId">{t("assignee")}</Label>
<select id="assigneeId" name="assigneeId" defaultValue={incident.assigneeId ?? ""} className={`${selectClass} mt-1 w-full`}>
<option value="">{tc("none")}</option>
{users.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
</select>
</div>
<Button type="submit" size="sm" variant="secondary">{tc("save")}</Button>
</form>
)}
{canManage && (
<form action={setIncidentRestricted.bind(null, incident.id)} className="rounded-xl border bg-card p-4">
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" name="restricted" defaultChecked={incident.restricted} /> {t("restrictedToggle")}
</label>
<Button type="submit" size="sm" variant="outline" className="mt-2">{tc("save")}</Button>
</form>
)}
</div>
</div>
{/* Meldepflicht & Fristen (§6) — Countdown + Meldung-Track-Steuerung */}
<div className="mx-5 mb-4 rounded-xl border bg-card p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-[13px] font-semibold">{t("reportingHead")}</p>
<span className="flex flex-wrap items-center gap-2">
{incident.nis2Relevant && <Pill tone="orange">NIS2</Pill>}
{incident.dsgvoRelevant && <Pill tone="warn">DSGVO</Pill>}
<Pill tone={REPORT_STATUS_TONE[incident.reportStatus] ?? "mut"}>
{t(`reportStatus_${incident.reportStatus}`)}
</Pill>
</span>
</div>
<p className="mt-0.5 text-[11.5px] text-muted-foreground">
{t("nis2CategoryLabel")}: {t(`nis2_${nis2Category}`)}
</p>
<div className="mt-3 grid gap-4 md:grid-cols-2">
<IncidentDeadlines
items={deadlineViews}
labels={{
meldungHead: t("dl_meldungHead"),
slaHead: t("dl_slaHead"),
remaining: t("dl_remaining"),
overdue: t("dl_overdue"),
submitted: t("dl_submitted"),
none: t("dl_none"),
}}
/>
{canManage && (
<div className="space-y-3">
<form action={setIncidentReportability.bind(null, incident.id)} className="space-y-1.5 rounded-lg border bg-[var(--surface-soft)] p-3">
<p className="text-[12px] font-semibold">{t("setReportability")}</p>
<label className="flex items-center gap-2 text-[12.5px]">
<input type="checkbox" name="nis2Relevant" defaultChecked={incident.nis2Relevant} /> {t("nis2Relevant")}
</label>
<label className="flex items-center gap-2 text-[12.5px]">
<input type="checkbox" name="personalData" defaultChecked={incident.personalData} /> {t("personalData")}
</label>
<Button type="submit" size="sm" variant="secondary">{t("applyReportability")}</Button>
<p className="text-[11px] text-muted-foreground">{t("reportabilityHint")}</p>
</form>
{incident.reportStatus !== "none" && nextReport && (
<form action={advanceReportStatus.bind(null, incident.id)} className="rounded-lg border bg-[var(--surface-soft)] p-3">
<input type="hidden" name="reportStatus" value={nextReport} />
<Button type="submit" size="sm" disabled={nextReport === "abschluss" && !canClose}>
{t("advanceTo")} {t(`reportStatus_${nextReport}`)}
</Button>
<p className="mt-1.5 text-[11px] text-muted-foreground">{t("manualSubmitHint")}</p>
</form>
)}
</div>
)}
</div>
</div>
{/* Behebung/Abschluss-Texte (read-only Anzeige) */}
{(incident.rootCause || incident.resolution || incident.closingNote || incident.lessonsLearned || incident.measuresEffectiveness || incident.postIncidentReview) && (
<div className="mx-5 mb-4 grid gap-3 rounded-xl border bg-[var(--surface-soft)] p-4 text-[12.5px] md:grid-cols-2">
{incident.rootCause && <div><p className="font-semibold">{t("rootCause")}</p><p className="text-muted-foreground">{incident.rootCause}</p></div>}
{incident.resolution && <div><p className="font-semibold">{t("resolution")}</p><p className="text-muted-foreground">{incident.resolution}</p></div>}
{incident.closingNote && <div><p className="font-semibold">{t("closingNote")}</p><p className="text-muted-foreground">{incident.closingNote}</p></div>}
{incident.lessonsLearned && <div><p className="font-semibold">{t("lessonsLearned")}</p><p className="text-muted-foreground">{incident.lessonsLearned}</p></div>}
{incident.measuresEffectiveness && <div><p className="font-semibold">{t("measuresEffectiveness")}</p><p className="text-muted-foreground">{incident.measuresEffectiveness}</p></div>}
{incident.postIncidentReview && <div><p className="font-semibold">{t("postIncidentReview")}</p><p className="text-muted-foreground">{incident.postIncidentReview}</p></div>}
</div>
)}
{/* Betroffenheit / Verknüpfungen (read-only Zusammenfassung; Pflege im Bearbeiten-Popup) */}
<div className="mx-5 mb-4 grid gap-3 text-sm md:grid-cols-2">
<LinkSummary label={t("assets")} items={incident.incidentAssets.map((x) => x.asset.name)} />
<LinkSummary label={t("processes")} items={incident.incidentProcesses.map((x) => x.process.name)} />
<LinkSummary label={t("risks")} items={incident.incidentRisks.map((x) => `${riskRef(x.risk.refNo)} ${x.risk.title}`)} />
<LinkSummary label={t("controls")} items={incident.incidentControls.map((x) => x.controlRef)} />
<LinkSummary
label={t("measures")}
items={incident.incidentMeasures.map(
(x) =>
`${measureRef(x.measure.refNo)} ${x.measure.title} · ${tMeasureStatus(x.measure.status)}` +
`${x.measure.owner?.name ? ` · ${x.measure.owner.name}` : ""}` +
`${x.measure.dueDate ? ` · ${fmtDate(x.measure.dueDate)}` : ""}`,
)}
/>
<LinkSummary label={t("evidence")} items={incident.incidentEvidence.map((x) => x.evidence.title + (x.note ? ` — ${x.note}` : ""))} />
</div>
{/* §8 — Post-Incident-Review + Wirksamkeit der Maßnahmen (speist Management-Review) */}
{canManage && (
<form action={setIncidentReview.bind(null, incident.id)} className="mx-5 mb-4 space-y-2 rounded-xl border bg-card p-4">
<p className="text-[13px] font-semibold">{t("reviewHead")}</p>
<p className="text-[11.5px] text-muted-foreground">{t("reviewHint")}</p>
<div>
<Label htmlFor="measuresEffectiveness">{t("measuresEffectiveness")}</Label>
<Textarea id="measuresEffectiveness" name="measuresEffectiveness" rows={2} defaultValue={incident.measuresEffectiveness ?? ""} className="mt-1 text-[12px]" />
</div>
<div>
<Label htmlFor="postIncidentReview">{t("postIncidentReview")}</Label>
<Textarea id="postIncidentReview" name="postIncidentReview" rows={2} defaultValue={incident.postIncidentReview ?? ""} className="mt-1 text-[12px]" />
</div>
<Button type="submit" size="sm" variant="secondary">{tc("save")}</Button>
</form>
)}
{/* Export / Nachweise (§9/§72) */}
<div className="mx-5 mb-4 flex flex-wrap items-center gap-2 rounded-xl border bg-[var(--surface-soft)] p-4">
<p className="mr-2 text-[13px] font-semibold">{t("exportHead")}</p>
<a href={`/incidents/export?format=report&id=${incident.id}`} target="_blank" rel="noreferrer" className="rounded-md border bg-card px-3 py-1.5 text-[12px] hover:bg-muted">
{t("exportReport")}
</a>
{incident.nis2Relevant && (
<a href={`/incidents/export?format=nis2&id=${incident.id}`} className="rounded-md border bg-card px-3 py-1.5 text-[12px] hover:bg-muted">
{t("exportNis2")}
</a>
)}
{incident.dsgvoRelevant && (
<a href={`/incidents/export?format=dsgvo&id=${incident.id}`} className="rounded-md border bg-card px-3 py-1.5 text-[12px] hover:bg-muted">
{t("exportDsgvo")}
</a>
)}
<span className="text-[11px] text-muted-foreground">{t("exportHint")}</span>
</div>
{/* Kommentar-Thread (§4) */}
<div className="mx-5 mb-4">
<p className="text-sm font-semibold">{t("comments")}</p>
<ul className="mt-2 space-y-2">
{incident.comments.length === 0 && <li className="text-sm text-muted-foreground">{t("noComments")}</li>}
{incident.comments.map((c) => (
<li key={c.id} className="rounded-lg border bg-[var(--surface-soft)] p-2.5 text-[12.5px]">
<div className="mb-0.5 flex items-center gap-2 text-[11px] text-muted-foreground">
<span className="font-semibold text-foreground">{c.authorId ? actorNames[c.authorId] ?? "—" : "System"}</span>
<span>{fmtDate(c.createdAt)}</span>
{c.internal && <Pill tone="mut">{t("internal")}</Pill>}
</div>
<p className="whitespace-pre-wrap">{c.body}</p>
</li>
))}
</ul>
{canReport && (
<form action={addIncidentComment.bind(null, incident.id)} className="mt-2 space-y-2">
<Textarea name="body" rows={2} required placeholder={t("commentPlaceholder")} />
<div className="flex items-center gap-3">
<Button type="submit" size="sm" variant="secondary"><Plus className="size-3.5" /> {t("addComment")}</Button>
<label className="flex items-center gap-2 text-[12px]">
<input type="checkbox" name="internal" /> {t("internal")}
</label>
</div>
</form>
)}
</div>
{/* Audit-Timeline (§8) — manipulationssicher, getrennt von Kommentaren */}
<div className="mx-5 mb-5">
<p className="text-sm font-semibold">{t("timeline")}</p>
<ul className="mt-2 space-y-1.5 text-[12px]">
{audit.length === 0 && <li className="text-muted-foreground">{t("noTimeline")}</li>}
{audit.map((a) => (
<li key={a.id} className="flex flex-wrap items-center gap-2 border-b py-1 last:border-0">
<span className="text-muted-foreground">{fmtDate(a.createdAt)}</span>
<span className="font-medium">{a.actorId ? actorNames[a.actorId] ?? "System" : "System"}</span>
<Tag>{a.action}</Tag>
{a.after != null && typeof a.after === "object" && (
<span className="text-muted-foreground">{JSON.stringify(a.after)}</span>
)}
</li>
))}
</ul>
</div>
</Modal>
);
}
function LinkSummary({ label, items }: { label: string; items: string[] }) {
return (
<div className="rounded-lg border bg-[var(--surface-soft)] p-3">
<p className="text-[12px] font-semibold">{label}</p>
{items.length === 0 ? (
<p className="text-[11.5px] text-muted-foreground">—</p>
) : (
<ul className="mt-1 space-y-0.5 text-[12px]">
{items.map((i, idx) => <li key={idx}>{i}</li>)}
</ul>
)}
</div>
);
}
/** Bearbeiten als Popup: Basisfelder + Verknüpfungspflege. */
export async function IncidentEditModal({
incident,
availableAssets,
availableProcesses,
availableRisks,
availableMeasures,
availableEvidence,
users,
}: {
incident: IncidentWithDetail;
availableAssets: { id: string; name: string }[];
availableProcesses: { id: string; name: string }[];
availableRisks: { id: string; refNo: number; title: string }[];
availableMeasures: { id: string; refNo: number; title: string }[];
availableEvidence: { id: string; title: string }[];
users: { id: string; name: string }[];
}) {
const t = await getTranslations("incidents");
const tc = await getTranslations("common");
return (
<Modal title={t("editTitle")} sub={`${incident.refNo} · ${incident.title}`} closeHref={`/incidents?detail=${incident.id}`} closeLabel={t("close")}>
<div className="grid gap-6 p-5 md:grid-cols-2">
<form key={incident.updatedAt.toISOString()} action={updateIncident.bind(null, incident.id)} className="space-y-4">
<IncidentFields incident={incident} />
<Button type="submit">{tc("save")}</Button>
</form>
<div className="space-y-5 text-sm">
<LinkEditor
label={t("assets")} addLabel={t("addAsset")} incidentId={incident.id}
items={incident.incidentAssets.map((x) => ({ id: x.id, name: x.asset.name }))}
options={availableAssets.map((a) => ({ value: a.id, label: a.name }))}
fieldName="assetId"
add={linkIncidentAsset.bind(null, incident.id)}
remove={unlinkIncidentAsset}
/>
<LinkEditor
label={t("processes")} addLabel={t("addProcess")} incidentId={incident.id}
items={incident.incidentProcesses.map((x) => ({ id: x.id, name: x.process.name }))}
options={availableProcesses.map((p) => ({ value: p.id, label: p.name }))}
fieldName="processId"
add={linkIncidentProcess.bind(null, incident.id)}
remove={unlinkIncidentProcess}
/>
{/* Risiken: bestehendes bestätigen ODER neu erzeugen (§9) */}
<div className="space-y-2 rounded-lg border bg-[var(--surface-soft)] p-3">
<LinkEditor
label={t("risks")} addLabel={t("addRisk")} incidentId={incident.id}
items={incident.incidentRisks.map((x) => ({ id: x.id, name: `${riskRef(x.risk.refNo)} ${x.risk.title}` }))}
options={availableRisks.map((r) => ({ value: r.id, label: `${riskRef(r.refNo)} ${r.title}` }))}
fieldName="riskId"
add={linkIncidentRisk.bind(null, incident.id)}
remove={unlinkIncidentRisk}
/>
<details className="text-[12px]">
<summary className="cursor-pointer font-medium">{t("newRiskFromIncident")}</summary>
<form action={createRiskForIncident.bind(null, incident.id)} className="mt-2 space-y-2">
<Input name="title" required placeholder={t("riskTitlePlaceholder")} className="h-9" />
<div className="flex items-center gap-2">
<Label htmlFor="risk-l" className="text-[11px]">{t("likelihood")}</Label>
<select id="risk-l" name="likelihood" defaultValue={3} className={`${selectClass} w-16`}>{[1, 2, 3, 4, 5].map((v) => <option key={v} value={v}>{v}</option>)}</select>
<Label htmlFor="risk-i" className="text-[11px]">{t("impact")}</Label>
<select id="risk-i" name="impact" defaultValue={3} className={`${selectClass} w-16`}>{[1, 2, 3, 4, 5].map((v) => <option key={v} value={v}>{v}</option>)}</select>
</div>
<Button type="submit" variant="secondary" size="sm">{t("createRisk")}</Button>
</form>
</details>
</div>
{/* Maßnahmen: bestehende verknüpfen ODER direkt aus dem Vorfall anlegen (§9, CAPA) */}
<div className="space-y-2 rounded-lg border bg-[var(--surface-soft)] p-3">
<LinkEditor
label={t("measures")} addLabel={t("addMeasure")} incidentId={incident.id}
items={incident.incidentMeasures.map((x) => ({ id: x.id, name: `${measureRef(x.measure.refNo)} ${x.measure.title} · ${tMeasureStatus(x.measure.status)}` }))}
options={availableMeasures.map((m) => ({ value: m.id, label: `${measureRef(m.refNo)} ${m.title}` }))}
fieldName="measureId"
add={linkIncidentMeasure.bind(null, incident.id)}
remove={unlinkIncidentMeasure}
/>
<details className="text-[12px]">
<summary className="cursor-pointer font-medium">{t("newMeasureFromIncident")}</summary>
<form action={createMeasureForIncident.bind(null, incident.id)} className="mt-2 space-y-2">
<Input name="title" required placeholder={t("measureTitlePlaceholder")} className="h-9" />
<div className="flex gap-2">
<select name="priority" defaultValue="MEDIUM" className={`${selectClass} flex-1`}>
<option value="LOW">{t("priorityLow")}</option>
<option value="MEDIUM">{t("priorityMedium")}</option>
<option value="HIGH">{t("priorityHigh")}</option>
</select>
<select name="ownerId" defaultValue="" className={`${selectClass} flex-1`}>
<option value="">{t("measureOwnerNone")}</option>
{users.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
</select>
</div>
<Input name="dueDate" type="date" className="h-9" />
<Button type="submit" variant="secondary" size="sm">{t("createMeasure")}</Button>
</form>
</details>
</div>
{/* Nachweise: bestehenden Nachweis verknüpfen ODER Referenz/Text anlegen (§9) */}
<div className="space-y-2 rounded-lg border bg-[var(--surface-soft)] p-3">
<p className="font-medium">{t("evidence")}</p>
{incident.incidentEvidence.length === 0 && <p className="text-xs text-muted-foreground">—</p>}
<ul className="space-y-1.5">
{incident.incidentEvidence.map((e) => (
<li key={e.id} className="flex items-center gap-2">
<span className="min-w-0 truncate">{e.evidence.title}{e.note ? ` — ${e.note}` : ""}</span>
<form action={unlinkIncidentEvidence.bind(null, incident.id, e.id)}>
<button type="submit" className="text-muted-foreground hover:text-destructive"><X className="size-3.5" /></button>
</form>
</li>
))}
</ul>
{availableEvidence.length > 0 && (
<form action={linkIncidentEvidence.bind(null, incident.id)} className="flex gap-2">
<select name="evidenceId" required className={`${selectClass} flex-1`}>
{availableEvidence.map((e) => <option key={e.id} value={e.id}>{e.title}</option>)}
</select>
<Button type="submit" variant="secondary" size="sm">{tc("add")}</Button>
</form>
)}
<details className="text-[12px]">
<summary className="cursor-pointer font-medium">{t("newEvidence")}</summary>
<form action={createEvidenceForIncident.bind(null, incident.id)} className="mt-2 space-y-2">
<Input name="title" required placeholder={t("evidenceTitlePlaceholder")} className="h-9" />
<Input name="fileRef" placeholder={t("evidenceRefPlaceholder")} className="h-9" />
<Button type="submit" variant="secondary" size="sm">{t("createEvidence")}</Button>
</form>
</details>
</div>
{/* Controls: freie Referenzeingabe (kein Katalog-FK) */}
<div>
<p className="font-medium">{t("controls")}</p>
<p className="mb-1 text-xs text-muted-foreground">{t("controlsHint")}</p>
<ul className="space-y-1.5">
{incident.incidentControls.map((c) => (
<li key={c.id} className="flex items-center gap-2">
{c.controlRef}
<form action={unlinkIncidentControl.bind(null, incident.id, c.id)}>
<button type="submit" title={tc("remove")} className="text-muted-foreground hover:text-destructive"><X className="size-3.5" /></button>
</form>
</li>
))}
</ul>
<form action={linkIncidentControl.bind(null, incident.id)} className="mt-2 flex gap-2">
<Input name="controlRef" required placeholder="z. B. 5.24" className="h-9 flex-1" />
<Button type="submit" variant="secondary" size="sm">{tc("add")}</Button>
</form>
</div>
</div>
</div>
<div className="border-t p-5 text-[12px] text-muted-foreground">
<Trash2 className="mr-1 inline size-3.5" /> {t("deleteHint")}
</div>
</Modal>
);
}
function LinkEditor({
label,
addLabel,
incidentId,
items,
options,
fieldName,
add,
remove,
}: {
label: string;
addLabel: string;
incidentId: string;
items: { id: string; name: string }[];
options: { value: string; label: string }[];
fieldName: string;
add: (formData: FormData) => void;
remove: (incidentId: string, linkId: string) => void;
}) {
return (
<div>
<p className="font-medium">{label}</p>
{items.length === 0 && <p className="text-xs text-muted-foreground">—</p>}
<ul className="space-y-1.5">
{items.map((it) => (
<li key={it.id} className="flex items-center gap-2">
<span className="min-w-0 truncate">{it.name}</span>
<form action={remove.bind(null, incidentId, it.id)}>
<button type="submit" className="text-muted-foreground hover:text-destructive"><X className="size-3.5" /></button>
</form>
</li>
))}
</ul>
{options.length > 0 && (
<form action={add} className="mt-2 flex gap-2">
<select name={fieldName} required className={`${selectClass} flex-1`}>
{options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<Button type="submit" variant="secondary" size="sm">{addLabel}</Button>
</form>
)}
</div>
);
}
-218
View File
@@ -1,218 +0,0 @@
"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;
// Cockpit (M3): optionaler Bereichs-Badge auf der Karte.
domainLabel?: string | null;
};
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,
onReorder,
detailBase = "/measures",
}: {
columns: KanbanColumn[];
canWrite: boolean;
// Server-Action: (itemId, newStatus)
onMove: (itemId: string, status: string) => Promise<void>;
// Cockpit (M3): optionale Persistenz der Reihenfolge einer Spalte (orderIdx).
// Erhält die neue, vollständige ID-Reihenfolge der Zielspalte.
onReorder?: (orderedIds: string[]) => Promise<void>;
// Basis-Route für das Detail-Popup beim Klick auf eine Karte (?detail=<id>).
detailBase?: string;
}) {
const router = useRouter();
const [, startTransition] = useTransition();
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } })
);
// Karten-ID → Spalte (für Drop-auf-Karte-Auflösung).
const statusOf = new Map<string, string>();
for (const col of columns) for (const c of col.cards) statusOf.set(c.id, col.status);
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over) return;
const activeId = String(active.id);
const fromStatus = active.data.current?.status as string;
// Ziel bestimmen: Drop auf eine Karte (card:<id>) oder auf eine Spalte (<status>).
const overId = String(over.id);
const overCardId = overId.startsWith("card:") ? overId.slice(5) : null;
const toStatus = overCardId ? statusOf.get(overCardId) ?? fromStatus : overId;
const statusChanged = fromStatus !== toStatus;
if (statusChanged) startTransition(() => onMove(activeId, toStatus));
// Reihenfolge in der Zielspalte neu berechnen und persistieren (falls unterstützt).
if (onReorder) {
const target = columns.find((c) => c.status === toStatus);
if (target) {
const ids = target.cards.map((c) => c.id).filter((id) => id !== activeId);
const insertAt = overCardId ? Math.max(0, ids.indexOf(overCardId)) : ids.length;
ids.splice(insertAt, 0, activeId);
const current = target.cards.map((c) => c.id);
if (statusChanged || ids.join() !== current.join()) {
startTransition(() => onReorder(ids));
}
}
}
}
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} sortable={!!onReorder} onOpen={(id) => router.push(`${detailBase}?detail=${id}`)} />
))}
</div>
</DndContext>
);
}
function Column({
column,
canWrite,
sortable,
onOpen,
}: {
column: KanbanColumn;
canWrite: boolean;
sortable: 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-[var(--primary)] bg-[var(--band)]"
)}
>
<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-[var(--elevated)] 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} sortable={sortable} onOpen={onOpen} />
))}
</div>
</div>
);
}
function Card({
card,
status,
canWrite,
sortable,
onOpen,
}: {
card: KanbanCard;
status: string;
canWrite: boolean;
sortable: boolean;
onOpen: (id: string) => void;
}) {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
id: card.id,
data: { status },
disabled: !canWrite,
});
// Cockpit (M3): Karte zugleich Drop-Ziel → Einsortieren vor dieser Karte.
const { setNodeRef: setDropRef, isOver } = useDroppable({ id: `card:${card.id}`, disabled: !sortable });
const setRefs = (node: HTMLElement | null) => {
setNodeRef(node);
setDropRef(node);
};
return (
<div
ref={setRefs}
{...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",
sortable && isOver && !isDragging && "border-[var(--primary)]"
)}
>
<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="flex items-center gap-1.5">
{card.domainLabel && (
<span className="rounded-full bg-[var(--sidebar-accent)] px-2 py-[1px] text-[10px] font-semibold text-[var(--primary)]">
{card.domainLabel}
</span>
)}
<span className={cn(card.overdue && "font-bold text-[var(--risk)]")}>
{card.dueLabel ?? "—"}
</span>
</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-[var(--sidebar-accent)] text-[10px] font-bold text-[var(--primary)]">
{card.ownerInitials}
</span>
)}
</span>
</div>
</div>
);
}
-275
View File
@@ -1,275 +0,0 @@
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), name: measure.title })}
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-[var(--primary)] bg-[var(--surface-soft)] 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")}: −{format.number(rm.reductionLikelihood, { maximumFractionDigits: 2 })} /
−{format.number(rm.reductionImpact, { maximumFractionDigits: 2 })}
</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>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { Construction } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { PageHead } from "@/components/mockup-ui";
import { MODULES, type ModuleKey } from "@/lib/modules";
/**
* Platzhalter-Seite eines noch nicht umgesetzten Moduls. Die jeweilige Modul-Lane
* ersetzt die `page.tsx` ihres Routenordners; `layout.tsx` (requireModule) bleibt.
*/
export async function ModulePlaceholder({ moduleKey }: { moduleKey: ModuleKey }) {
const t = await getTranslations("modules");
const mod = MODULES.find((m) => m.key === moduleKey)!;
return (
<main className="flex-1 p-6">
<PageHead crumb={t("placeholder.crumb")} title={t(mod.nav)} />
<div className="shadow-card mt-4 flex items-start gap-3 rounded-xl border bg-card p-5">
<Construction className="mt-0.5 size-5 shrink-0 text-[var(--ui-accent)]" aria-hidden />
<div>
<p className="font-heading text-sm font-semibold">{t("placeholder.status")}</p>
<p className="mt-1 text-[13px] text-muted-foreground">{t("placeholder.hint")}</p>
</div>
</div>
</main>
);
}
-114
View File
@@ -1,114 +0,0 @@
import { requireSession } from "@/server/auth";
import { dbForTenant } from "@/server/db";
import { hasPermission } from "@/server/rbac";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Pill } from "@/components/mockup-ui";
import { eligibleValidators, latestReviewTask } from "@/server/object-review";
import { REVIEW_LABEL, REVIEW_TONE, reviewState } from "@/lib/object-review";
import { approveTask, rejectTask } from "@/server/actions/tasks";
const inputCls = "h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm";
/**
* Generisches Objekt-Review (Story A3-1). Zeigt den aus der jüngsten Review-Aufgabe
* abgeleiteten Status eines beliebigen Objekts und bietet — je nach Rolle —
* Einreichen zur Validierung (Vier-Augen) bzw. Validieren/Zurückweisen an.
*
* `submit` ist die objekt-modulspezifische Einreich-Action (z. B. `submitRiskForReview`
* gebunden an die Objekt-ID); sie erwartet `approverId` und optional `note` im FormData.
*/
export async function ObjectReview({
entityType,
entityId,
title,
submit,
canSubmit,
}: {
entityType: string;
entityId: string;
entityRef?: string | null;
title: string;
submit: (formData: FormData) => Promise<void>;
canSubmit: boolean;
}) {
const session = await requireSession();
const db = dbForTenant(session.user.tenantId);
const task = await latestReviewTask(db, entityType, entityId);
const state = reviewState(task);
const isOpen = task?.status === "OPEN";
const isAssignee = task?.assigneeId === session.user.id;
const canValidate = hasPermission(session, "validate_objects");
const validators = !isOpen && canSubmit ? await eligibleValidators(db, session.user.id) : [];
const names = new Map<string, string>();
const ids = [task?.assigneeId, task?.createdById, task?.resolvedById].filter(Boolean) as string[];
if (ids.length) {
(await db.user.findMany({ where: { id: { in: ids } }, select: { id: true, name: true } })).forEach((u) => names.set(u.id, u.name));
}
const lastReviewComment = [...(task?.comments ?? [])].reverse().find((c) => c.kind === "reject" || c.kind === "approve");
return (
<section className="rounded-xl border bg-card p-4">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className="font-heading text-sm font-semibold">Validierung</span>
<Pill tone={REVIEW_TONE[state]}>{REVIEW_LABEL[state]}</Pill>
</div>
{task && <span className="text-[11.5px] text-muted-foreground">nur „Bestätigt“ zählt als abgesichert</span>}
</div>
{/* Laufende Validierung */}
{isOpen && (
<div className="mt-3 text-[12.5px]">
<p className="text-muted-foreground">Zur Validierung bei <b>{names.get(task!.assigneeId ?? "") ?? "—"}</b>{task!.createdById ? <> · eingereicht von {names.get(task!.createdById) ?? "—"}</> : null}.</p>
{isAssignee && canValidate ? (
<div className="mt-2 flex flex-wrap items-center gap-2">
<form action={approveTask.bind(null, task!.id)}>
<Button type="submit" size="sm">Validieren</Button>
</form>
<details className="relative">
<summary className="inline-flex h-8 cursor-pointer list-none items-center rounded-md border border-input px-2.5 text-sm font-medium select-none hover:bg-muted [&::-webkit-details-marker]:hidden">Zurückweisen</summary>
<form action={rejectTask.bind(null, task!.id)} className="shadow-card absolute left-0 z-10 mt-2 w-80 space-y-2 rounded-xl border bg-card p-3">
<Textarea name="note" rows={3} placeholder="Grund der Rückweisung" required />
<Button type="submit" variant="secondary" size="sm">Zurückweisen</Button>
</form>
</details>
</div>
) : (
<p className="mt-1 text-muted-foreground">Wartet auf Entscheidung des Validators.</p>
)}
</div>
)}
{/* Abgeschlossenes Review + Begründung */}
{!isOpen && lastReviewComment && (
<p className="mt-2 text-[12.5px] text-muted-foreground">
{state === "bestaetigt" ? "Bestätigt" : "Zurückgewiesen"} von {names.get(task!.resolvedById ?? "") ?? "—"}: „{lastReviewComment.body}“
</p>
)}
{/* Einreichen zur Validierung */}
{!isOpen && canSubmit && (
validators.length === 0 ? (
<p className="mt-3 text-[12.5px] text-muted-foreground">Kein aktiver Validator (Recht „validate_objects“) verfügbar.</p>
) : (
<form action={submit} className="mt-3 space-y-2">
<p className="text-[12.5px] text-muted-foreground">
{state === "zurueckgewiesen" ? "Nach Nacharbeit erneut zur Validierung einreichen:" : "Zur Validierung einreichen (Vier-Augen):"}
</p>
<div className="grid gap-2 sm:grid-cols-[minmax(0,16rem)_1fr] sm:items-center">
<select name="approverId" required className={inputCls} defaultValue="">
<option value="" disabled>Validator wählen…</option>
{validators.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
</select>
<input name="note" placeholder="Notiz (optional)" className={inputCls} />
</div>
<Button type="submit" size="sm" title={title}>Zur Validierung einreichen</Button>
</form>
)
)}
</section>
);
}
-235
View File
@@ -1,235 +0,0 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Send } from "lucide-react";
import { Pill, Tag } from "@/components/mockup-ui";
import { Button } from "@/components/ui/button";
import { compareControl } from "@/lib/control-titles";
import { DOMAIN_LABELS, DOMAIN_ORDER, DOMAIN_FUNCTION, defaultDomainForControl } from "@/lib/control-domain";
import { submitDeriveDomains, setPolicyDomain, submitForApproval } from "@/server/actions/policies";
import type { TenantDb } from "@/server/db";
import type { Domain } from "@prisma/client";
const TYPE_LABEL: Record<string, string> = {
LEITLINIE: "Leitlinie",
RICHTLINIE: "Richtlinie",
VERFAHREN: "Verfahren",
REGISTER: "Register",
HANDBUCH: "Handbuch",
EIGENES: "Eigenes",
};
const STATUS_TONE: Record<string, "ok" | "info" | "warn" | "mut"> = {
FREIGEGEBEN: "ok",
IN_FREIGABE: "info",
ENTWURF: "warn",
ARCHIVIERT: "mut",
};
const STATUS_LABEL: Record<string, string> = {
FREIGEGEBEN: "Freigegeben",
IN_FREIGABE: "In Freigabe",
ENTWURF: "Entwurf",
ARCHIVIERT: "Archiviert",
};
/** Dokumenttypen, die aus der Ansicht heraus bearbeitet/eingereicht werden können. */
const EDITABLE_TYPES = new Set(["LEITLINIE", "RICHTLINIE", "VERFAHREN"]);
/**
* Geteilte „Richtlinien nach Fachbereich"-Ansicht.
*
* Rendert die Richtlinien gruppiert nach Fachbereich (Domain) mit
* Verantwortlichem je Bereich sowie — bei Schreibrecht (`canWrite`) — der
* Fachbereichs-Zuordnung (`setPolicyDomain`), „Bearbeiten" (Editor-Link),
* „Zur Prüfung geben" (`submitForApproval`, nur ENTWURF) und der
* Domain-Ableitung (`submitDeriveDomains`).
*
* Wird sowohl vom eigenständigen Richtlinien-Modul (`/policies?view=domains`)
* als auch vom Onboarding-Schritt „Leitlinie & Richtlinien" genutzt — die
* Fachbereichs-Logik liegt damit an genau einer Stelle. Die Komponente lädt
* ihre Daten selbst über den mandantengebundenen `db`. `currentUserId` schließt
* den anfragenden Nutzer aus der Freigeber-Auswahl aus (Vier-Augen). Ohne
* `canWrite` wird die Ansicht rein lesend gerendert (Editier-Spalten entfallen).
*
* @param compact Kontext-Flag (z. B. Onboarding): blendet den erklärenden
* Intro-Hinweis aus, die Ableiten-Aktion bleibt erhalten.
*/
export async function PolicyDomainView({
db,
currentUserId,
canWrite,
compact = false,
returnTo,
}: {
db: TenantDb;
currentUserId: string;
canWrite: boolean;
compact?: boolean;
/** Interner Pfad, zu dem „Fachbereiche ableiten"/„Zur Prüfung geben" zurückkehren
* (z. B. "/onboarding?step=policy", damit man im Wizard bleibt). Sonst /policies-Default. */
returnTo?: string;
}) {
const t = await getTranslations("policies");
const [docs, requirements, assignments, approverUsers] = await Promise.all([
db.policyDocument.findMany({ where: { archivedAt: null }, orderBy: { orderIdx: "asc" } }),
db.policyRequirement.findMany({
where: { archivedAt: null },
select: { control: true, policyCode: true, vaCodes: true },
}),
db.projectFunctionAssignment.findMany({ select: { functionKey: true, userId: true, domain: true } }),
db.user.findMany({
where: {
status: "ACTIVE",
id: { not: currentUserId },
userRoles: { some: { role: { rolePermissions: { some: { permission: { key: "policy:approve" } } } } } },
},
select: { id: true, name: true },
orderBy: { name: "asc" },
}),
]);
// Zuständigkeit je Bereich: explizite Bereichszuordnung, sonst Default-Funktion.
const userIds = [...new Set(assignments.map((a) => a.userId).filter(Boolean) as string[])];
const respUsers = userIds.length
? await db.user.findMany({ where: { id: { in: userIds } }, select: { id: true, name: true } })
: [];
const userName = (id: string | null) => respUsers.find((u) => u.id === id)?.name ?? null;
const primaryControl = (code: string): string | null => {
const own = requirements.filter((r) => r.policyCode === code);
const list = own.length ? own : requirements.filter((r) => r.vaCodes.includes(code));
if (!list.length) return null;
return [...list].map((r) => r.control).sort(compareControl)[0] ?? null;
};
const domainOf = (d: (typeof docs)[number]): Domain | null => {
if (d.domain) return d.domain;
const pc = primaryControl(d.code);
return pc ? defaultDomainForControl(pc) : null;
};
const responsibleFor = (domain: Domain): string | null => {
const byDomain = assignments.find((a) => a.domain === domain && a.userId);
if (byDomain) return userName(byDomain.userId);
const fk = DOMAIN_FUNCTION[domain];
const byFn = fk ? assignments.find((a) => a.functionKey === fk && a.userId) : undefined;
return byFn ? userName(byFn.userId) : null;
};
// Gruppieren (aktive Bibliothek). Reihenfolge: DOMAIN_ORDER, dann „ohne Fachbereich".
const groups = new Map<Domain | "none", typeof docs>();
for (const key of DOMAIN_ORDER) groups.set(key, []);
groups.set("none", []);
for (const d of docs) {
const key = domainOf(d) ?? "none";
groups.get(key)!.push(d);
}
const orderedKeys: (Domain | "none")[] = [...DOMAIN_ORDER, "none"];
return (
<>
{(!compact || canWrite) && (
<div className="flex flex-wrap items-center justify-between gap-3">
{compact ? (
<span />
) : (
<p className="text-[12.5px] text-muted-foreground">
Richtlinien nach Fachbereich (aus dem primären Control abgeleitet, manuell überschreibbar).
</p>
)}
{canWrite && (
<form action={submitDeriveDomains}>
{returnTo && <input type="hidden" name="returnTo" value={returnTo} />}
<Button type="submit" variant="outline" size="sm">{t("deriveDomains")}</Button>
</form>
)}
</div>
)}
<div className="mt-4 space-y-5">
{orderedKeys.map((key) => {
const list = groups.get(key)!;
if (!list.length) return null;
const isNone = key === "none";
const resp = isNone ? null : responsibleFor(key);
const fk = isNone ? null : DOMAIN_FUNCTION[key];
return (
<div key={key} className="shadow-card overflow-hidden rounded-xl border bg-card">
<div className="flex flex-wrap items-center justify-between gap-2 border-b bg-[var(--surface-soft)] px-4 py-3">
<p className="font-heading text-sm font-bold">
{isNone ? t("domainNone") : DOMAIN_LABELS[key]}{" "}
<span className="text-[11px] font-normal text-muted-foreground">· {list.length}</span>
</p>
{!isNone && (
<p className="text-[12px] text-muted-foreground">
{t("domainResponsible")}: <b className="text-foreground">{resp ?? t("domainUnassigned")}</b>
{fk && <span className="ml-1 text-[11px]">({fk})</span>}
</p>
)}
</div>
<div className="overflow-x-auto">
<table className="w-full text-[12.5px]">
<thead>
<tr className="border-b bg-[var(--elevated)] text-[10.5px] tracking-[.04em] text-muted-foreground uppercase">
<th className="p-3 text-left">{t("code")}</th>
<th className="p-3 text-left">{t("docTitle")}</th>
<th className="p-3 text-left">{t("type")}</th>
<th className="p-3 text-left">{t("status")}</th>
{canWrite && <th className="p-3 text-left">{t("byDomain")}</th>}
{canWrite && <th className="p-3 text-left">{t("actions")}</th>}
</tr>
</thead>
<tbody>
{list.map((d) => (
<tr key={d.id} className="border-b align-middle last:border-0">
<td className="p-3 text-muted-foreground">{d.code}</td>
<td className="p-3">
<Link href={`/policies/${d.code}`} className="font-bold hover:underline">{d.title}</Link>
</td>
<td className="p-3"><Tag>{TYPE_LABEL[d.type]}</Tag></td>
<td className="p-3"><Pill tone={STATUS_TONE[d.status]}>{STATUS_LABEL[d.status]}</Pill></td>
{canWrite && (
<td className="p-3">
<form action={setPolicyDomain.bind(null, d.code)} className="flex items-center gap-1.5">
<select name="domain" defaultValue={d.domain ?? ""} className="h-7 rounded-md border border-input bg-transparent px-1.5 text-[11.5px]">
<option value="">— {t("domainSet")} —</option>
{DOMAIN_ORDER.map((dom) => (
<option key={dom} value={dom}>{DOMAIN_LABELS[dom]}</option>
))}
</select>
<Button type="submit" variant="outline" size="sm" className="h-7 px-2 text-[11px]">OK</Button>
</form>
</td>
)}
{canWrite && (
<td className="p-3">
<div className="flex flex-wrap items-center gap-2">
{EDITABLE_TYPES.has(d.type) && (
<Button variant="outline" size="sm" nativeButton={false} render={<Link href={`/policies/${d.code}/edit`} />}>{t("edit")}</Button>
)}
{d.status === "ENTWURF" && (
approverUsers.length === 0 ? (
<Button variant="outline" size="sm" nativeButton={false} render={<Link href={`/policies/${d.code}/edit`} />}>{t("submitReview")}</Button>
) : (
<form action={submitForApproval.bind(null, d.code)} className="flex items-center gap-1.5">
{returnTo && <input type="hidden" name="returnTo" value={returnTo} />}
<select name="approverId" required defaultValue="" className="h-7 rounded-md border border-input bg-transparent px-1.5 text-[11.5px]">
<option value="" disabled>— Freigeber —</option>
{approverUsers.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
</select>
<Button type="submit" size="sm" className="h-7 px-2 text-[11px]"><Send className="size-3.5" /> {t("submitReview")}</Button>
</form>
)
)}
</div>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
})}
</div>
</>
);
}
-126
View File
@@ -1,126 +0,0 @@
"use client";
import { useRef, useState } from "react";
import { Bold, Italic, Heading, List, Table, Link2, Plus } from "lucide-react";
/**
* Experten-Editor (Client): voller Markdown-/Vorlagen-Editor mit Formatierungs-
* leiste und Einfüge-Hilfen für Variablen, Dokument-Links und Control-Verweise.
* Speichert über die übergebene Server-Action; fehlende {{VARIABLEN}} werden
* beim Speichern serverseitig automatisch angelegt.
*/
export function PolicyExpertEditor({
formId,
saveAction,
initialMarkdown,
variables,
docs,
}: {
formId: string;
saveAction: (formData: FormData) => void | Promise<void>;
initialMarkdown: string;
variables: { key: string; title: string }[];
docs: { code: string; title: string }[];
}) {
const ref = useRef<HTMLTextAreaElement>(null);
const [value, setValue] = useState(initialMarkdown);
const [newVar, setNewVar] = useState("");
function apply(fn: (sel: string, before: string) => { text: string; caret?: number }) {
const ta = ref.current;
if (!ta) return;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const sel = value.slice(start, end);
const before = value.slice(0, start);
const { text } = fn(sel, before);
const next = before + text + value.slice(end);
setValue(next);
requestAnimationFrame(() => {
ta.focus();
const pos = start + text.length;
ta.setSelectionRange(pos, pos);
});
}
const wrap = (b: string, a: string, placeholder: string) =>
apply((sel) => ({ text: `${b}${sel || placeholder}${a}` }));
const insert = (t: string) => apply(() => ({ text: t }));
const insertVar = (key: string) => key && insert(`{{${key}}}`);
const addNewVar = () => {
const k = newVar.trim().toUpperCase().replace(/[^A-Z0-9_]/g, "_");
if (k) {
insert(`{{${k}}}`);
setNewVar("");
}
};
const btn = "inline-flex h-8 items-center gap-1.5 rounded-md border border-input bg-transparent px-2.5 text-[12px] font-medium text-muted-foreground hover:bg-muted hover:text-foreground";
const sel = "h-8 rounded-md border border-input bg-transparent px-2 text-[12px] text-muted-foreground";
return (
<div>
{/* Werkzeugleiste */}
<div className="mb-2 flex flex-wrap items-center gap-1.5">
<button type="button" className={btn} onClick={() => wrap("**", "**", "fett")} title="Fett"><Bold className="size-3.5" /></button>
<button type="button" className={btn} onClick={() => wrap("_", "_", "kursiv")} title="Kursiv"><Italic className="size-3.5" /></button>
<button type="button" className={btn} onClick={() => apply((sel) => ({ text: `\n## ${sel || "Überschrift"}\n` }))} title="Überschrift"><Heading className="size-3.5" /></button>
<button type="button" className={btn} onClick={() => apply((sel) => ({ text: `\n- ${sel || "Punkt"}\n` }))} title="Liste"><List className="size-3.5" /></button>
<button type="button" className={btn} onClick={() => insert("\n| Spalte A | Spalte B |\n|----------|----------|\n| Wert | Wert |\n")} title="Tabelle"><Table className="size-3.5" /></button>
<span className="mx-1 h-5 w-px bg-border" />
{/* Variablen einfügen */}
<select className={sel} defaultValue="" onChange={(e) => { insertVar(e.target.value); e.currentTarget.value = ""; }} title="Variable einfügen">
<option value="" disabled>Variable einfügen…</option>
{variables.map((v) => (
<option key={v.key} value={v.key}>{v.key} — {v.title}</option>
))}
</select>
{/* Dokument-Link / Control-Referenz einfügen */}
<select className={sel} defaultValue="" onChange={(e) => { if (e.target.value) insert(`{{LINK:${e.target.value}}}`); e.currentTarget.value = ""; }} title="Verweis einfügen">
<option value="" disabled>Verweis einfügen…</option>
<optgroup label="Dokumente & Register">
{docs.map((d) => (
<option key={d.code} value={d.code}>{d.code} — {d.title}</option>
))}
</optgroup>
<optgroup label="Zentrale Verweise">
<option value="NACHWEISREGISTER">Nachweisregister</option>
<option value="ISA_MAPPING">ISA-Mapping-Matrix</option>
<option value="BASELINE">Technische Baseline</option>
</optgroup>
</select>
<span className="inline-flex items-center gap-1">
<input
value={newVar}
onChange={(e) => setNewVar(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addNewVar(); } }}
placeholder="Neue Variable"
className="h-8 w-28 rounded-md border border-input bg-transparent px-2 text-[12px]"
/>
<button type="button" className={btn} onClick={addNewVar} title="Neue Variable einfügen"><Plus className="size-3.5" /></button>
</span>
</div>
<p className="mb-2 flex items-center gap-1.5 text-[11.5px] text-muted-foreground">
<Link2 className="size-3.5" /> Deep-Link-Syntax: <code className="rounded bg-[var(--elevated)] px-1">{"{{LINK:R08#4.1.2}}"}</code> verweist auf ein Control. Neue Variablen werden beim Speichern angelegt.
</p>
<form id={formId} action={saveAction}>
<textarea
ref={ref}
name="rawMarkdown"
value={value}
onChange={(e) => setValue(e.target.value)}
spellCheck={false}
rows={32}
className="w-full rounded-xl border border-input bg-transparent p-3 font-mono text-[12px] leading-relaxed outline-none focus:border-[var(--primary)]"
/>
</form>
</div>
);
}
-116
View File
@@ -1,116 +0,0 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import type { PolicyDocument, PolicyVariable } from "@prisma/client";
import { Pill } from "@/components/mockup-ui";
import { applyProtection, buildContext, renderPolicyHtml, splitPolicyDoc } from "@/lib/policy-render";
export const POLICY_TYPE_LABEL: Record<string, string> = {
LEITLINIE: "Leitlinie",
RICHTLINIE: "Richtlinie",
VERFAHREN: "Verfahren",
REGISTER: "Register",
HANDBUCH: "Handbuch",
EIGENES: "Eigenes",
};
export const POLICY_STATUS_TONE: Record<string, "ok" | "info" | "warn" | "mut"> = {
FREIGEGEBEN: "ok",
IN_FREIGABE: "info",
ENTWURF: "warn",
ARCHIVIERT: "mut",
};
export const POLICY_STATUS_LABEL: Record<string, string> = {
FREIGEGEBEN: "Freigegeben",
IN_FREIGABE: "In Freigabe",
ENTWURF: "Entwurf",
ARCHIVIERT: "Archiviert",
};
// Dokumente, die eine verwaltete Register-Tabelle einbetten (§7b) → „Vollständige Tabelle öffnen"
const EMBEDDED_REGISTER: Record<string, { code: string; label: string }> = {
R09: { code: "CRYPTO", label: "Verschlüsselungsmechanismen-Register" },
"VA-07": { code: "CRYPTO", label: "Verschlüsselungsmechanismen-Register" },
R03: { code: "RISKMATRIX", label: "Risiko-Bewertungsmatrix" },
"VA-09": { code: "RISKMATRIX", label: "Risiko-Bewertungsmatrix" },
R02: { code: "CLASSIFICATION", label: "Klassifizierung & Handhabungsmatrix" },
"VA-08": { code: "CLASSIFICATION", label: "Klassifizierung & Handhabungsmatrix" },
};
export interface PolicyReadData {
doc: PolicyDocument;
variables: PolicyVariable[];
/** ISA-Controls dieser Richtlinie (für Chips). */
controls: string[];
/** Operationalisierende Verfahren (Codes) — aus den Anforderungen der Richtlinie. */
relatedVas: string[];
}
/** Lesemodus-Inhalt: gerendertes Dokument aus der echten Markdown-Vorlage (auf eigener Seite). */
export async function PolicyReadView({ data }: { data: PolicyReadData }) {
const t = await getTranslations("policies");
const { doc, variables, controls, relatedVas } = data;
const ctx = applyProtection(buildContext(variables));
const { infoMd, bodyMd } = splitPolicyDoc(doc.rawMarkdown);
const stripBaseline = doc.code !== "BASELINE";
const infoHtml = renderPolicyHtml(infoMd, ctx, { readMode: true, stripBaseline: false });
const bodyHtml = renderPolicyHtml(bodyMd, ctx, { readMode: true, stripBaseline });
return (
<div>
{/* Kontext: Controls / operationalisiert-durch */}
{(controls.length > 0 || doc.policyCode || relatedVas.length > 0) && (
<div className="mb-4 flex flex-wrap items-center gap-2 text-[12px]">
{doc.type === "VERFAHREN" && doc.policyCode && (
<span className="text-muted-foreground">
{t("operationalizes")}{" "}
<Link href={`/policies/${doc.policyCode}`} className="font-semibold text-[var(--info)] hover:underline">
{doc.policyCode}
</Link>
</span>
)}
{controls.map((c) => (
<Pill key={c} tone="violet">
ISA {c}
</Pill>
))}
{doc.type !== "VERFAHREN" && relatedVas.length > 0 && (
<span className="ml-1 flex flex-wrap items-center gap-1.5 text-muted-foreground">
{t("procedures")}:
{relatedVas.map((va) => (
<Link key={va} href={`/policies/${va}`} className="font-semibold text-[var(--info)] hover:underline">
{va}
</Link>
))}
</span>
)}
</div>
)}
{/* Einklappbare Dokumenten-Info (§7a.5, Standard: eingeklappt) */}
{infoMd && (
<details className="mb-4 rounded-xl border bg-[var(--surface-soft)]">
<summary className="cursor-pointer list-none px-4 py-2.5 text-[12.5px] font-semibold text-muted-foreground select-none hover:text-foreground [&::-webkit-details-marker]:hidden">
{t("docInfo")}
</summary>
<div className="policy-prose border-t px-4 py-2" dangerouslySetInnerHTML={{ __html: infoHtml }} />
</details>
)}
{/* Eingebettete verwaltete Tabelle (§7b) */}
{EMBEDDED_REGISTER[doc.code] && (
<Link
href={`/policies/${EMBEDDED_REGISTER[doc.code].code}`}
className="mb-4 flex items-center justify-between gap-2 rounded-xl border border-[var(--band-brd)] bg-[var(--band)] px-4 py-2.5 text-[12.5px] text-[var(--band-text)] hover:opacity-90"
>
<span>
<b>Verwaltete Tabelle:</b> {EMBEDDED_REGISTER[doc.code].label}
</span>
<span className="font-semibold">{t("openFullTable")} →</span>
</Link>
)}
{/* Gerendertes Dokument */}
<article className="policy-prose" dangerouslySetInnerHTML={{ __html: bodyHtml }} />
</div>
);
}
-411
View File
@@ -1,411 +0,0 @@
import Link from "next/link";
import { getFormatter } from "next-intl/server";
import { Pencil, Plus, Trash2 } from "lucide-react";
import type {
ClassificationClass,
CryptoEntry,
HandbookTopic,
HandlingAspect,
HandlingRule,
PolicyDocument,
PolicyVariable,
RiskDamageDimension,
RiskEwLevel,
RiskMatrixClass,
} from "@prisma/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Pill } from "@/components/mockup-ui";
import { isExpired, isExpiring } from "@/lib/supplier";
import { applyProtection, buildContext, renderPolicyHtml, resolveLink } from "@/lib/policy-render";
import {
addClassificationClass,
addCryptoEntry,
addHandlingAspect,
deleteCryptoEntry,
updateCryptoEntry,
updateDamageDimension,
updateEwLevel,
updateHandlingRule,
updateRiskClass,
} from "@/server/actions/policies";
const TONE_HEX: Record<string, string> = { ok: "#2ea86b", warn: "#e3b427", orange: "#e2802e", risk: "#d63c5e" };
export interface RegisterData {
crypto?: CryptoEntry[];
classes?: ClassificationClass[];
aspects?: HandlingAspect[];
rules?: HandlingRule[];
riskClasses?: RiskMatrixClass[];
ewLevels?: RiskEwLevel[];
damage?: RiskDamageDimension[];
handbook?: HandbookTopic[];
variables?: PolicyVariable[];
}
/** Inhalt der verwalteten Register (§7b) und des Anwender-Handbuchs (§9.7) — auf eigener Seite. */
export async function PolicyRegisterView({
doc,
data,
canWrite,
}: {
doc: PolicyDocument;
data: RegisterData;
canWrite: boolean;
}) {
return (
<div>
{doc.code === "CRYPTO" && <CryptoRegister entries={data.crypto ?? []} canWrite={canWrite} />}
{doc.code === "CLASSIFICATION" && (
<ClassificationMatrix classes={data.classes ?? []} aspects={data.aspects ?? []} rules={data.rules ?? []} canWrite={canWrite} />
)}
{doc.code === "RISKMATRIX" && (
<RiskMatrix classes={data.riskClasses ?? []} ew={data.ewLevels ?? []} damage={data.damage ?? []} canWrite={canWrite} />
)}
{doc.code === "HANDBUCH" && <Handbook topics={data.handbook ?? []} variables={data.variables ?? []} />}
</div>
);
}
/* ─────────────── Krypto-Register ─────────────── */
async function CryptoRegister({ entries, canWrite }: { entries: CryptoEntry[]; canWrite: boolean }) {
const fmt = await getFormatter();
const date = (d: Date | null) => (d ? fmt.dateTime(d, { dateStyle: "medium" }) : "—");
const expiring = entries.filter((e) => isExpiring(e.ablaufdatum) || isExpired(e.ablaufdatum)).length;
return (
<div>
<div className="mb-3 flex items-center justify-between gap-2">
<p className="text-[12.5px] text-muted-foreground">
Eingesetzte Verschlüsselung mit Ablaufüberwachung.{" "}
{expiring > 0 && <span className="font-semibold text-[var(--warn)]">{expiring} laufen bald ab / abgelaufen.</span>}
</p>
{canWrite && (
<details className="relative">
<summary className="bg-grad-soft inline-flex h-8 cursor-pointer list-none items-center gap-1.5 rounded-lg px-2.5 font-heading text-sm font-semibold text-white select-none hover:opacity-90 [&::-webkit-details-marker]:hidden">
<Plus className="size-4" /> Eintrag
</summary>
<form action={addCryptoEntry} className="shadow-card absolute right-0 z-10 mt-2 w-80 space-y-2 rounded-xl border bg-card p-3 text-sm">
<Input name="dienst" required placeholder="Dienst" />
<Input name="schluessel" required placeholder="Schlüssel" />
<Input name="algorithmus" placeholder="Algorithmus / Länge" />
<Input name="ablaufdatum" type="date" />
<Input name="verantwortlich" placeholder="Verantwortlich" />
<Input name="speicherort" placeholder="Speicherort" />
<Input name="baselineRef" placeholder="Baseline (BL-CRY-…)" />
<Button type="submit" variant="secondary" size="sm">Hinzufügen</Button>
</form>
</details>
)}
</div>
<div className="rounded-xl border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-[var(--elevated)] text-[10.5px] tracking-[.04em] text-muted-foreground uppercase">
<th className="p-2.5 text-left">Dienst</th>
<th className="p-2.5 text-left">Schlüssel</th>
<th className="p-2.5 text-left">Algorithmus</th>
<th className="p-2.5 text-left">Ablauf</th>
<th className="p-2.5 text-left">Verantwortlich</th>
<th className="p-2.5 text-left">Baseline</th>
{canWrite && <th className="p-2.5" />}
</tr>
</thead>
<tbody>
{entries.length === 0 && (
<tr><td colSpan={7} className="p-6 text-center text-muted-foreground">Noch keine Einträge.</td></tr>
)}
{entries.map((e) => {
const exp = isExpired(e.ablaufdatum);
const soon = !exp && isExpiring(e.ablaufdatum);
return (
<tr key={e.id} className="border-b last:border-0">
<td className="p-2.5 font-medium">{e.dienst}</td>
<td className="p-2.5 text-muted-foreground">{e.schluessel}</td>
<td className="p-2.5 text-muted-foreground">{e.algorithmus ?? "—"}</td>
<td className="p-2.5">
<span className={exp ? "font-semibold text-[var(--risk)]" : soon ? "font-semibold text-[var(--warn)]" : "text-muted-foreground"}>
{date(e.ablaufdatum)}
</span>
{exp && <Pill tone="risk">abgelaufen</Pill>}
{soon && <Pill tone="warn">läuft ab</Pill>}
</td>
<td className="p-2.5 text-muted-foreground">{e.verantwortlich}</td>
<td className="p-2.5">{e.baselineRef ? <Pill tone="violet">{e.baselineRef}</Pill> : <span className="text-muted-foreground">—</span>}</td>
{canWrite && (
<td className="p-2.5">
<div className="flex items-center justify-end gap-1">
<details className="relative">
<summary className="grid size-7 cursor-pointer list-none place-items-center rounded-md text-muted-foreground hover:bg-muted [&::-webkit-details-marker]:hidden"><Pencil className="size-3.5" /></summary>
<form action={updateCryptoEntry.bind(null, e.id)} className="shadow-card absolute right-0 z-10 mt-2 w-72 space-y-2 rounded-xl border bg-card p-3 text-sm">
<Input name="dienst" required defaultValue={e.dienst} />
<Input name="schluessel" required defaultValue={e.schluessel} />
<Input name="algorithmus" defaultValue={e.algorithmus ?? ""} placeholder="Algorithmus" />
<Input name="ablaufdatum" type="date" defaultValue={e.ablaufdatum ? e.ablaufdatum.toISOString().slice(0, 10) : ""} />
<Input name="verantwortlich" defaultValue={e.verantwortlich} />
<Input name="speicherort" defaultValue={e.speicherort ?? ""} placeholder="Speicherort" />
<Input name="baselineRef" defaultValue={e.baselineRef ?? ""} placeholder="BL-CRY-…" />
<Button type="submit" variant="secondary" size="sm">Speichern</Button>
</form>
</details>
<form action={deleteCryptoEntry.bind(null, e.id)}>
<button type="submit" title="Löschen" className="grid size-7 place-items-center rounded-md text-muted-foreground hover:text-destructive"><Trash2 className="size-3.5" /></button>
</form>
</div>
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
/* ─────────────── Klassifizierungs-Handhabungsmatrix ─────────────── */
function ClassificationMatrix({
classes, aspects, rules, canWrite,
}: { classes: ClassificationClass[]; aspects: HandlingAspect[]; rules: HandlingRule[]; canWrite: boolean }) {
const ruleOf = (classId: string, aspectId: string) => rules.find((r) => r.classId === classId && r.aspectId === aspectId)?.text ?? "";
return (
<div>
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<p className="text-[12.5px] text-muted-foreground">Handhabungsregeln je Schutzklasse. Assets erben die Regeln aus ihrer Klasse.</p>
{canWrite && (
<div className="flex gap-2">
<details className="relative">
<summary className="inline-flex h-8 cursor-pointer list-none items-center gap-1.5 rounded-lg border px-2.5 text-[12.5px] font-semibold text-muted-foreground hover:text-foreground [&::-webkit-details-marker]:hidden"><Plus className="size-3.5" /> Aspekt</summary>
<form action={addHandlingAspect} className="shadow-card absolute right-0 z-10 mt-2 w-72 space-y-2 rounded-xl border bg-card p-3 text-sm">
<Input name="name" required placeholder="Aspekt (Zeile)" />
<Input name="category" placeholder="Kategorie" />
<Button type="submit" variant="secondary" size="sm">Hinzufügen</Button>
</form>
</details>
<details className="relative">
<summary className="inline-flex h-8 cursor-pointer list-none items-center gap-1.5 rounded-lg border px-2.5 text-[12.5px] font-semibold text-muted-foreground hover:text-foreground [&::-webkit-details-marker]:hidden"><Plus className="size-3.5" /> Schutzklasse</summary>
<form action={addClassificationClass} className="shadow-card absolute right-0 z-10 mt-2 w-72 space-y-2 rounded-xl border bg-card p-3 text-sm">
<Input name="name" required placeholder="Schutzklasse (Spalte)" />
<Input name="description" placeholder="Beschreibung" />
<Button type="submit" variant="secondary" size="sm">Hinzufügen</Button>
</form>
</details>
</div>
)}
</div>
<div className="overflow-x-auto rounded-xl border">
<table className="w-full text-[12.5px]">
<thead>
<tr className="border-b bg-[var(--elevated)]">
<th className="p-2.5 text-left text-[10.5px] tracking-[.04em] text-muted-foreground uppercase">Aspekt</th>
{classes.map((c) => (
<th key={c.id} className="min-w-40 p-2.5 text-left font-heading font-bold" title={c.description ?? ""}>{c.name}</th>
))}
</tr>
</thead>
<tbody>
{aspects.map((a) => (
<tr key={a.id} className="border-b last:border-0 align-top">
<td className="p-2.5 font-medium">{a.name}</td>
{classes.map((c) => (
<td key={c.id} className="p-1.5">
{canWrite ? (
<details className="group">
<summary className="cursor-pointer list-none rounded-md px-2 py-1.5 hover:bg-muted/50 [&::-webkit-details-marker]:hidden">{ruleOf(c.id, a.id) || <span className="text-muted-foreground">—</span>}</summary>
<form action={updateHandlingRule.bind(null, c.id, a.id)} className="mt-1 flex gap-1">
<Input name="text" defaultValue={ruleOf(c.id, a.id)} className="h-8" />
<Button type="submit" variant="secondary" size="sm">OK</Button>
</form>
</details>
) : (
<span className="px-2">{ruleOf(c.id, a.id) || "—"}</span>
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
/* ─────────────── Risiko-Bewertungsmatrix ─────────────── */
function RiskMatrix({
classes, ew, damage, canWrite,
}: { classes: RiskMatrixClass[]; ew: RiskEwLevel[]; damage: RiskDamageDimension[]; canWrite: boolean }) {
const sorted = [...classes].sort((a, b) => a.maxScore - b.maxScore);
const classFor = (score: number) => sorted.find((c) => score <= c.maxScore) ?? sorted[sorted.length - 1];
const rows = [4, 3, 2, 1]; // Schadensausmaß (oben = 4)
const cols = [1, 2, 3, 4]; // Eintrittswahrscheinlichkeit
return (
<div className="space-y-5">
{/* 4×4 Heatmap */}
<section>
<p className="mb-2 font-heading text-sm font-semibold">Bewertungsmatrix (Schadensausmaß × Eintrittswahrscheinlichkeit)</p>
<div className="inline-block overflow-hidden rounded-xl border">
<table className="text-center text-[12.5px]">
<tbody>
{rows.map((s) => (
<tr key={s}>
<th className="bg-[var(--elevated)] px-3 py-2 text-muted-foreground">S {s}</th>
{cols.map((e) => {
const score = s * e;
const cls = classFor(score);
return (
<td key={e} className="px-4 py-2 font-bold text-white" style={{ background: TONE_HEX[cls?.tone] ?? "#555" }} title={`${cls?.name} (${score})`}>
{score}
</td>
);
})}
</tr>
))}
<tr>
<th className="bg-[var(--elevated)] px-3 py-2" />
{cols.map((e) => (
<th key={e} className="bg-[var(--elevated)] px-4 py-2 text-muted-foreground">EW {e}</th>
))}
</tr>
</tbody>
</table>
</div>
</section>
{/* Risikoklassen + Akzeptanzinstanz — inline editierbar */}
<section>
<p className="mb-2 font-heading text-sm font-semibold">Risikoklassen & Akzeptanz {canWrite && <span className="text-[11px] font-normal text-muted-foreground">· zum Bearbeiten auf eine Zeile klicken</span>}</p>
<div className="rounded-xl border">
<div className="grid grid-cols-[8rem_7rem_1fr] gap-x-3 border-b bg-[var(--elevated)] px-3 py-2 text-[10.5px] tracking-[.04em] text-muted-foreground uppercase">
<span>Klasse</span><span>Bis Risikowert</span><span>Akzeptanzinstanz</span>
</div>
{sorted.map((c) => (
canWrite ? (
<details key={c.id} className="border-b last:border-0">
<summary className="grid cursor-pointer grid-cols-[8rem_7rem_1fr] items-center gap-x-3 px-3 py-2.5 text-sm select-none hover:bg-muted/40 [&::-webkit-details-marker]:hidden">
<span><Pill tone={c.tone as "ok" | "warn" | "orange" | "risk"}>{c.name}</Pill></span>
<span className="font-medium">≤ {c.maxScore}</span>
<span className="flex items-center justify-between gap-2 text-muted-foreground">{c.acceptance}<Pencil className="size-3.5 shrink-0 opacity-60" /></span>
</summary>
<form action={updateRiskClass.bind(null, c.id)} className="grid gap-2 border-t bg-[var(--surface-soft)] p-3 sm:grid-cols-[8rem_7rem_1fr_auto]">
<Input name="name" required defaultValue={c.name} placeholder="Klasse" className="h-8" />
<Input name="maxScore" type="number" required defaultValue={c.maxScore} placeholder="≤ Wert" className="h-8" />
<Input name="acceptance" defaultValue={c.acceptance} placeholder="Akzeptanzinstanz" className="h-8" />
<Button type="submit" variant="secondary" size="sm">Speichern</Button>
</form>
</details>
) : (
<div key={c.id} className="grid grid-cols-[8rem_7rem_1fr] items-center gap-x-3 border-b px-3 py-2.5 text-sm last:border-0">
<span><Pill tone={c.tone as "ok" | "warn" | "orange" | "risk"}>{c.name}</Pill></span>
<span className="font-medium">≤ {c.maxScore}</span>
<span className="text-muted-foreground">{c.acceptance}</span>
</div>
)
))}
</div>
</section>
{/* Eintrittswahrscheinlichkeit — inline editierbar */}
<section>
<p className="mb-2 font-heading text-sm font-semibold">Eintrittswahrscheinlichkeit</p>
<div className="rounded-xl border">
{[...ew].sort((a, b) => a.level - b.level).map((l) => (
canWrite ? (
<details key={l.id} className="border-b last:border-0">
<summary className="flex cursor-pointer items-center justify-between gap-2 px-3 py-2 text-[12.5px] select-none hover:bg-muted/40 [&::-webkit-details-marker]:hidden">
<span><b>{l.level} · {l.label}</b> <span className="text-muted-foreground">— {l.definition}</span></span>
<Pencil className="size-3.5 shrink-0 opacity-60" />
</summary>
<form action={updateEwLevel.bind(null, l.id)} className="grid gap-2 border-t bg-[var(--surface-soft)] p-3 sm:grid-cols-[10rem_1fr_auto]">
<Input name="label" required defaultValue={l.label} placeholder="Bezeichnung" className="h-8" />
<Input name="definition" defaultValue={l.definition} placeholder="Definition" className="h-8" />
<Button type="submit" variant="secondary" size="sm">Speichern</Button>
</form>
</details>
) : (
<div key={l.id} className="border-b px-3 py-2 text-[12.5px] last:border-0"><b>{l.level} · {l.label}</b> <span className="text-muted-foreground">— {l.definition}</span></div>
)
))}
</div>
</section>
{/* Schadenskategorien — inline editierbar */}
<section>
<p className="mb-2 font-heading text-sm font-semibold">Schadenskategorien</p>
<div className="rounded-xl border">
<div className="hidden grid-cols-[12rem_1fr_1fr_1fr_1fr] gap-x-3 border-b bg-[var(--elevated)] px-3 py-2 text-[10.5px] tracking-[.04em] text-muted-foreground uppercase sm:grid">
<span>Dimension</span><span>1 Niedrig</span><span>2 Normal</span><span>3 Hoch</span><span>4 Sehr hoch</span>
</div>
{damage.map((d) => {
const lv = d.levels as Record<string, string>;
return canWrite ? (
<details key={d.id} className="border-b last:border-0">
<summary className="grid cursor-pointer grid-cols-1 items-center gap-x-3 px-3 py-2.5 text-[12px] select-none hover:bg-muted/40 sm:grid-cols-[12rem_1fr_1fr_1fr_1fr] [&::-webkit-details-marker]:hidden">
<span className="flex items-center justify-between gap-2 font-medium">{d.name}<Pencil className="size-3.5 shrink-0 opacity-60 sm:hidden" /></span>
<span className="text-muted-foreground">{lv["1"]}</span><span className="text-muted-foreground">{lv["2"]}</span><span className="text-muted-foreground">{lv["3"]}</span><span className="text-muted-foreground">{lv["4"]}</span>
</summary>
<form action={updateDamageDimension.bind(null, d.id)} className="grid gap-2 border-t bg-[var(--surface-soft)] p-3 sm:grid-cols-2">
<Input name="name" required defaultValue={d.name} placeholder="Dimension" className="h-8 sm:col-span-2" />
<Input name="l1" defaultValue={lv["1"]} placeholder="1 Niedrig" className="h-8" />
<Input name="l2" defaultValue={lv["2"]} placeholder="2 Normal" className="h-8" />
<Input name="l3" defaultValue={lv["3"]} placeholder="3 Hoch" className="h-8" />
<Input name="l4" defaultValue={lv["4"]} placeholder="4 Sehr hoch" className="h-8" />
<Button type="submit" variant="secondary" size="sm" className="sm:col-span-2">Speichern</Button>
</form>
</details>
) : (
<div key={d.id} className="grid grid-cols-1 gap-x-3 border-b px-3 py-2.5 text-[12px] last:border-0 sm:grid-cols-[12rem_1fr_1fr_1fr_1fr]">
<span className="font-medium">{d.name}</span>
<span className="text-muted-foreground">{lv["1"]}</span><span className="text-muted-foreground">{lv["2"]}</span><span className="text-muted-foreground">{lv["3"]}</span><span className="text-muted-foreground">{lv["4"]}</span>
</div>
);
})}
</div>
</section>
</div>
);
}
/* ─────────────── Anwender-Handbuch ─────────────── */
function Handbook({ topics, variables }: { topics: HandbookTopic[]; variables: PolicyVariable[] }) {
const ctx = applyProtection(buildContext(variables));
const cats = [...new Set(topics.map((t) => t.category))];
return (
<div className="space-y-5">
<p className="text-[12.5px] text-muted-foreground">
Kompakte Regeln für Mitarbeitende. Werte (z. B. Passwortlänge) stammen aus der zentralen Baseline und bleiben automatisch synchron.
</p>
{cats.map((cat) => (
<section key={cat}>
<p className="mb-2 font-heading text-sm font-semibold text-[var(--band-text)]">{cat}</p>
<div className="space-y-3">
{topics.filter((t) => t.category === cat).map((topic) => (
<div key={topic.id} className="rounded-xl border border-l-[3px] border-l-[var(--primary)] bg-[var(--surface-soft)] p-4">
<p className="font-heading text-[14px] font-bold">{topic.title}</p>
<div className="policy-prose mt-1" dangerouslySetInnerHTML={{ __html: renderPolicyHtml(topic.bodyMd, ctx, { readMode: false }) }} />
{topic.sourceRefs.length > 0 && (
<div className="mt-2 flex flex-wrap items-center gap-2 text-[12px] text-muted-foreground">
Details:
{topic.sourceRefs.map((ref) => {
const { href, label } = resolveLink(ref);
return (
<Link key={ref} href={href} className="font-semibold text-[var(--info)] hover:underline">{label} →</Link>
);
})}
</div>
)}
</div>
))}
</div>
</section>
))}
</div>
);
}
-142
View File
@@ -1,142 +0,0 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import type { Process } from "@prisma/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
/** Formular für Anlegen/Bearbeiten eines Prozesses (Server-Action wird übergeben). */
export async function ProcessForm({
action,
process,
users,
processes = [],
cancelHref,
returnTo,
}: {
action: (formData: FormData) => Promise<void>;
process?: Process;
users: { id: string; name: string }[];
/** Auswahl möglicher Hauptprozesse (Haupt-/Teilprozess-Hierarchie). */
processes?: { id: string; name: string }[];
cancelHref: string;
/** Weiterleitung nach dem Anlegen ("house" → direkt ins BIA-Popup). */
returnTo?: string;
}) {
const t = await getTranslations("processes");
const tCat = await getTranslations("processCategory");
const tc = await getTranslations("common");
const selectClass = "mt-1 h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm";
return (
<form action={action} className="max-w-2xl space-y-4">
{returnTo && <input type="hidden" name="returnTo" value={returnTo} />}
<div>
<Label htmlFor="name">{t("name")}</Label>
<Input id="name" name="name" required defaultValue={process?.name} className="mt-1" />
</div>
<div>
<Label htmlFor="purpose">{t("purpose")}</Label>
<Textarea
id="purpose"
name="purpose"
rows={2}
defaultValue={process?.purpose ?? ""}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="description">{t("description")}</Label>
<Textarea
id="description"
name="description"
rows={3}
defaultValue={process?.description ?? ""}
className="mt-1"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="category">{t("category")}</Label>
<select id="category" name="category" defaultValue={process?.category ?? "CORE"} className={selectClass}>
{(["CORE", "MANAGEMENT", "SUPPORT"] as const).map((v) => (
<option key={v} value={v}>
{tCat(v)}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="parentId">{t("parent")}</Label>
<select id="parentId" name="parentId" defaultValue={process?.parentId ?? ""} className={selectClass}>
<option value="">{t("noParent")}</option>
{processes
.filter((p) => p.id !== process?.id)
.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="ownerId">{t("owner")}</Label>
<select id="ownerId" name="ownerId" defaultValue={process?.ownerId ?? ""} className={selectClass}>
<option value="">{tc("none")}</option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="deputyOwnerId">{t("deputyOwner")}</Label>
<select id="deputyOwnerId" name="deputyOwnerId" defaultValue={process?.deputyOwnerId ?? ""} className={selectClass}>
<option value="">{t("noDeputy")}</option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="legalBasis">{t("legalBasis")}</Label>
<Input id="legalBasis" name="legalBasis" defaultValue={process?.legalBasis ?? ""} className="mt-1" />
</div>
<div>
<Label htmlFor="interfaces">{t("interfaces")}</Label>
<Input id="interfaces" name="interfaces" defaultValue={process?.interfaces ?? ""} className="mt-1" />
</div>
</div>
<div className="flex flex-wrap gap-5">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="dataProtectionRelevant"
defaultChecked={process?.dataProtectionRelevant ?? false}
className="size-4 rounded border-input"
/>
{t("dataProtectionRelevant")}
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="prototypeRelevant"
defaultChecked={process?.prototypeRelevant ?? false}
className="size-4 rounded border-input"
/>
{t("prototypeRelevant")}
</label>
</div>
<div className="flex gap-2 pt-2">
<Button type="submit">{tc("save")}</Button>
<Button variant="outline" nativeButton={false} render={<Link href={cancelHref} />}>
{tc("cancel")}
</Button>
</div>
</form>
);
}
-927
View File
@@ -1,927 +0,0 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { MoreHorizontal, Network, Pencil, Trash2, X } from "lucide-react";
import type { Prisma } from "@prisma/client";
import {
addProcessDependency,
assignAsset,
createProcess,
deleteProcess,
removeProcessDependency,
saveProcessAll,
unassignAsset,
} from "@/server/actions/processes";
import { ProcessForm } from "@/components/process-form";
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 { SegmentedRating } from "@/components/segmented-rating";
import { CiaBadge, CiaLegend, CriticalityPill, Pill, Tag } from "@/components/mockup-ui";
import { riskLevel, riskRef, RISK_PILL_TONE } from "@/lib/risk";
export type LinkedRisk = {
id: string;
refNo: number;
title: string;
score: number;
};
export type ProcessWithDetail = Prisma.ProcessGetPayload<{
include: {
owner: { select: { name: true } };
parent: { select: { name: true } };
bia: true;
processAssets: {
include: {
asset: {
select: {
id: true;
name: true;
type: true;
confidentiality: true;
integrity: true;
availability: true;
owner: { select: { name: true } };
};
};
};
};
dependsOn: { select: { id: true; note: true; target: { select: { id: true; name: true } } } };
requiredBy: { select: { id: true; note: true; source: { select: { id: true; name: true } } } };
};
}>;
/** Anlegen als Popup (Stammdaten; Assets/BIA folgen im Bearbeiten-Popup bzw. BIA-Popup). */
export async function ProcessCreateModal({
users,
processes = [],
closeHref = "/processes",
returnTo,
}: {
users: { id: string; name: string }[];
processes?: { id: string; name: string }[];
/** Ziel für Schließen/Abbrechen (Overlay). Prozesshaus: „/onboarding?step=processes". */
closeHref?: string;
/** Weiterleitung nach dem Anlegen ("house" → direkt ins BIA-Popup). */
returnTo?: string;
}) {
const t = await getTranslations("processes");
return (
<Modal title={t("createTitle")} closeHref={closeHref} closeLabel={t("close")}>
<div className="p-5">
<ProcessForm
action={createProcess}
users={users}
processes={processes}
cancelHref={closeHref}
returnTo={returnTo}
/>
</div>
</Modal>
);
}
/** Payload für das read-only Prozesshaus-Detail-Overlay (fachliche Infos + BIA-Kurz). */
export type ProcessHouseDetail = Prisma.ProcessGetPayload<{
include: {
owner: { select: { name: true } };
parent: { select: { name: true } };
children: { select: { id: true; name: true } };
bia: true;
};
}>;
/**
* TISAX v4B — „Details" im Prozesshaus als read-only Overlay-Popup. Zeigt die
* fachlichen Prozess-Informationen (Zweck, Owner/Stellvertreter, Hierarchie,
* Rechtsgrundlage, Schnittstellen, DS-/Prototyp-Relevanz) plus eine BIA-Kurzübersicht
* — OHNE die Prozesshaus-Hauptseite zu verändern (wird als Sibling über die Seite
* gelegt, Zustand via `?detail=` der Onboarding-Seite). „Details" ≠ „BIA öffnen":
* Letzteres bleibt der Bearbeitungs-Flow (optionaler Button `biaHref`).
*/
export async function ProcessHouseDetailModal({
process,
deputyName,
closeHref,
biaHref,
}: {
process: ProcessHouseDetail;
/** Aufgelöster Name des Stellvertreters (deputyOwnerId hat keine Prisma-Relation). */
deputyName?: string | null;
/** Ziel für Schließen (zurück ins Prozesshaus, ohne die Hauptseite zu ändern). */
closeHref: string;
/** Optional: Link ins geführte BIA-Popup (Bearbeitungs-Flow). */
biaHref?: string;
}) {
const t = await getTranslations("processes");
const tHouse = await getTranslations("processHouse");
const tCrit = await getTranslations("criticality");
const tCat = await getTranslations("processCategory");
const tc = await getTranslations("common");
const hours = (v: number | null | undefined) => (v != null ? `${v} h` : tc("none"));
return (
<Modal
title={t("detailHeading", { name: process.name })}
sub={tHouse("detailSub")}
headerExtra={
<span className="flex items-center gap-2">
<Tag>{tCat(process.category)}</Tag>
{process.bia && (
<CriticalityPill
level={process.bia.criticality}
label={t("critLabel", { label: tCrit(String(process.bia.criticality)) })}
/>
)}
</span>
}
closeHref={closeHref}
closeLabel={t("close")}
footer={
<>
{biaHref && (
<Button nativeButton={false} render={<Link href={biaHref} />}>
{tHouse("openBia")}
</Button>
)}
<Button variant="outline" nativeButton={false} render={<Link href={closeHref} />}>
{t("close")}
</Button>
</>
}
>
{/* Fachliche Prozess-Informationen (read-only) */}
<div className="mx-5 mt-5 rounded-xl border bg-[var(--surface-soft)] p-4">
<p className="mb-2 font-heading text-[13px] font-semibold">{t("businessInfo")}</p>
<dl className="grid gap-x-6 gap-y-1.5 text-[12.5px] sm:grid-cols-2">
<div className="sm:col-span-2">
<dt className="text-muted-foreground">{t("purpose")}</dt>
<dd>{process.purpose || tc("none")}</dd>
</div>
<div>
<dt className="text-muted-foreground">{t("owner")}</dt>
<dd>{process.owner?.name ?? tc("none")}</dd>
</div>
<div>
<dt className="text-muted-foreground">{t("deputyOwner")}</dt>
<dd>{deputyName ?? t("noDeputy")}</dd>
</div>
<div>
<dt className="text-muted-foreground">{t("parent")}</dt>
<dd>{process.parent?.name ?? t("noParent")}</dd>
</div>
<div>
<dt className="text-muted-foreground">{tHouse("children")}</dt>
<dd>
{process.children.length === 0
? tHouse("noChildren")
: process.children.map((c) => c.name).join(", ")}
</dd>
</div>
<div>
<dt className="text-muted-foreground">{t("legalBasis")}</dt>
<dd>{process.legalBasis || tc("none")}</dd>
</div>
<div>
<dt className="text-muted-foreground">{t("interfaces")}</dt>
<dd>{process.interfaces || tc("none")}</dd>
</div>
<div className="sm:col-span-2 flex flex-wrap gap-2 pt-1">
{process.dataProtectionRelevant && <Pill tone="info">{t("dataProtectionRelevant")}</Pill>}
{process.prototypeRelevant && <Pill tone="violet">{t("prototypeRelevant")}</Pill>}
{process.catalogCode && <Tag>{t("catalogCode")}: {process.catalogCode}</Tag>}
</div>
</dl>
</div>
{/* BIA-Kurzübersicht (read-only) */}
<div className="mx-5 mt-4 mb-5 rounded-xl border border-[var(--band-brd)] bg-[var(--band)] p-4 text-[12.5px] text-[var(--band-text)]">
<div className="grid gap-2 sm:grid-cols-4">
<div>
<b>RTO</b>
<div className="mt-0.5">{hours(process.bia?.rtoHours)}</div>
</div>
<div>
<b>RPO</b>
<div className="mt-0.5">{hours(process.bia?.rpoHours)}</div>
</div>
<div>
<b>MTD</b>
<div className="mt-0.5">{hours(process.bia?.mtdHours)}</div>
</div>
<div>
<b>{t("impactShort")}</b>
<div className="mt-1">
{process.bia ? (
<CiaBadge c={process.bia.impactC} i={process.bia.impactI} a={process.bia.impactA} />
) : (
t("noBia")
)}
</div>
</div>
</div>
{process.bia?.notes && (
<p className="mt-3 border-t border-[var(--band-brd)] pt-2.5">{process.bia.notes}</p>
)}
</div>
</Modal>
);
}
/** Read-only-Prozess-Detail als Popup (Mockup: "Prozess-Detail · …"). */
export async function ProcessDetailModal({
process,
risks,
canWrite,
deputyName,
}: {
process: ProcessWithDetail;
risks: LinkedRisk[];
canWrite: boolean;
/** Aufgelöster Name des Stellvertreters (deputyOwnerId hat keine Prisma-Relation). */
deputyName?: string | null;
}) {
const t = await getTranslations("processes");
const tAssets = await getTranslations("assets");
const tType = await getTranslations("assetType");
const tCrit = await getTranslations("criticality");
const tCat = await getTranslations("processCategory");
const tLevel = await getTranslations("riskLevel");
const tDep = await getTranslations("dependencies");
const tc = await getTranslations("common");
const hours = (v: number | null | undefined) => (v != null ? `${v} h` : tc("none"));
const primary = process.processAssets.filter((pa) => pa.role === "PRIMARY");
const secondary = process.processAssets.filter((pa) => pa.role === "SECONDARY");
return (
<Modal
title={t("detailHeading", { name: process.name })}
sub={t("detailSub")}
headerExtra={
<span className="flex items-center gap-2">
<Tag>{tCat(process.category)}</Tag>
{process.bia && (
<CriticalityPill
level={process.bia.criticality}
label={t("critLabel", { label: tCrit(String(process.bia.criticality)) })}
/>
)}
</span>
}
closeHref="/processes"
closeLabel={t("close")}
footer={
<>
{canWrite && (
<Button
variant="outline"
nativeButton={false}
render={<Link href={`/processes?edit=${process.id}`} />}
>
<Pencil className="size-4" /> {tc("edit")}
</Button>
)}
<Button nativeButton={false} render={<Link href="/processes" />}>
{t("close")}
</Button>
</>
}
>
<div className="grid gap-5 p-5 md:grid-cols-2">
<div>
<div className="mb-2.5 flex items-center gap-2.5">
<Pill tone="violet">{t("primaryPill")}</Pill>
<span className="text-[12.5px] text-muted-foreground">{t("primaryNote")}</span>
</div>
{primary.length === 0 && <p className="text-sm text-muted-foreground">{t("noAssets")}</p>}
{primary.map((pa) => (
<div
key={pa.id}
className="mb-2 rounded-xl border border-l-[3px] border-l-[var(--primary)] bg-[var(--surface-soft)] p-4"
>
<div className="flex items-center justify-between gap-2">
<b>{pa.asset.name}</b>
<CiaBadge
c={pa.asset.confidentiality}
i={pa.asset.integrity}
a={pa.asset.availability}
labels
/>
</div>
<div className="mt-1.5 text-[12.5px] leading-relaxed text-muted-foreground">
{t("owner")}: {pa.asset.owner?.name ?? tc("none")} · {tAssets("type")}:{" "}
{tType(pa.asset.type)} · {t("inherits")}
</div>
</div>
))}
</div>
<div>
<div className="mb-2.5 flex items-center gap-2.5">
<Pill tone="info">{t("secondaryPill")}</Pill>
<span className="text-[12.5px] text-muted-foreground">{t("secondaryNote")}</span>
</div>
{secondary.length === 0 && <p className="text-sm text-muted-foreground">{t("noAssets")}</p>}
<table className="w-full text-sm">
<thead>
<tr>
<th />
<th />
<th className="pb-1 text-right font-normal">
<CiaLegend />
</th>
</tr>
</thead>
<tbody>
{secondary.map((pa) => (
<tr key={pa.id} className="border-b last:border-0">
<td className="py-2.5 pr-2 font-bold">{pa.asset.name}</td>
<td className="py-2.5 pr-2">
<Tag>{tType(pa.asset.type)}</Tag>
</td>
<td className="py-2.5 text-right">
<CiaBadge
c={pa.asset.confidentiality}
i={pa.asset.integrity}
a={pa.asset.availability}
/>
</td>
</tr>
))}
</tbody>
</table>
<Link
href="/dependencies"
className="mt-3 inline-flex items-center gap-1.5 text-[12.5px] font-semibold text-[var(--info)] hover:underline"
>
<Network className="size-3.5" /> {tDep("openGraph")} →
</Link>
</div>
</div>
{/* v3A: fachliche Prozess-Informationen */}
<div className="mx-5 mb-5 rounded-xl border bg-[var(--surface-soft)] p-4">
<p className="mb-2 font-heading text-[13px] font-semibold">{t("businessInfo")}</p>
<dl className="grid gap-x-6 gap-y-1.5 text-[12.5px] sm:grid-cols-2">
<div className="sm:col-span-2">
<dt className="text-muted-foreground">{t("purpose")}</dt>
<dd>{process.purpose || tc("none")}</dd>
</div>
<div>
<dt className="text-muted-foreground">{t("parent")}</dt>
<dd>{process.parent?.name ?? t("noParent")}</dd>
</div>
<div>
<dt className="text-muted-foreground">{t("deputyOwner")}</dt>
<dd>{deputyName ?? t("noDeputy")}</dd>
</div>
<div>
<dt className="text-muted-foreground">{t("legalBasis")}</dt>
<dd>{process.legalBasis || tc("none")}</dd>
</div>
<div>
<dt className="text-muted-foreground">{t("interfaces")}</dt>
<dd>{process.interfaces || tc("none")}</dd>
</div>
<div className="sm:col-span-2 flex flex-wrap gap-2 pt-1">
{process.dataProtectionRelevant && <Pill tone="info">{t("dataProtectionRelevant")}</Pill>}
{process.prototypeRelevant && <Pill tone="violet">{t("prototypeRelevant")}</Pill>}
{process.catalogCode && <Tag>{t("catalogCode")}: {process.catalogCode}</Tag>}
</div>
</dl>
</div>
{/* Prozess-Abhängigkeiten (benötigt / wird benötigt von) */}
<div className="mx-5 mb-5 rounded-xl border bg-[var(--surface-soft)] p-4">
<p className="mb-2 font-heading text-[13px] font-semibold">{t("depsSection")}</p>
<dl className="grid gap-x-6 gap-y-2 text-[12.5px] sm:grid-cols-2">
<div>
<dt className="text-muted-foreground">↳ {t("depsRequires")}</dt>
<dd className="mt-1 flex flex-wrap gap-1.5">
{process.dependsOn.length === 0
? tc("none")
: process.dependsOn.map((d) => (
<Pill key={d.id} tone="violet">
{d.target.name}
{d.note ? ` · ${d.note}` : ""}
</Pill>
))}
</dd>
</div>
<div>
<dt className="text-muted-foreground">▲ {t("depsRequiredBy")}</dt>
<dd className="mt-1 flex flex-wrap gap-1.5">
{process.requiredBy.length === 0
? tc("none")
: process.requiredBy.map((d) => (
<Pill key={d.id} tone="info">
{d.source.name}
</Pill>
))}
</dd>
</div>
</dl>
</div>
<div className="mx-5 mb-5 rounded-xl border border-[var(--band-brd)] bg-[var(--band)] p-4 text-[12.5px] text-[var(--band-text)]">
<div className="grid gap-2 sm:grid-cols-4">
<div>
<b>RTO</b>
<div className="mt-0.5">{hours(process.bia?.rtoHours)}</div>
</div>
<div>
<b>RPO</b>
<div className="mt-0.5">{hours(process.bia?.rpoHours)}</div>
</div>
<div>
<b>MTD</b>
<div className="mt-0.5">{hours(process.bia?.mtdHours)}</div>
</div>
<div>
<b>{t("impactShort")}</b>
<div className="mt-1">
{process.bia ? (
<CiaBadge c={process.bia.impactC} i={process.bia.impactI} a={process.bia.impactA} />
) : (
t("noBia")
)}
</div>
</div>
</div>
{process.bia?.notes && (
<p className="mt-3 border-t border-[var(--band-brd)] pt-2.5">{process.bia.notes}</p>
)}
</div>
<div className="mx-5 mb-5">
<p className="text-sm font-semibold">{t("linkedRisks")}</p>
{risks.length === 0 && (
<p className="mt-1 text-sm text-muted-foreground">{tc("none")}</p>
)}
<ul className="mt-1.5 space-y-1.5 text-sm">
{risks.map((r) => (
<li key={r.id} className="flex items-center gap-2">
<Link href={`/risks?detail=${r.id}`} className="font-bold hover:underline">
{riskRef(r.refNo)}
</Link>
<Link href={`/risks?detail=${r.id}`} className="hover:underline">
{r.title}
</Link>
<Pill tone={RISK_PILL_TONE[riskLevel(r.score)]}>
{r.score} · {tLevel(riskLevel(r.score))}
</Pill>
</li>
))}
</ul>
</div>
</Modal>
);
}
/**
* Bearbeiten im selben Popup — aufgeräumt (Referenz docs/ISMS-GUI-Verbesserungen):
* Sprungnavigation, genau EIN Speichern-Button (Stammdaten + BIA über die
* HTML-`form`-Association verbunden), Löschen im ⋯-Überlaufmenü. Die
* Asset-Zuordnung bleibt inkrementell (Chips add/remove).
*/
export async function ProcessEditModal({
process,
users,
availableAssets,
processes = [],
}: {
process: ProcessWithDetail;
users: { id: string; name: string }[];
availableAssets: { id: string; name: string }[];
/** Auswahl möglicher Hauptprozesse (Haupt-/Teilprozess-Hierarchie). */
processes?: { id: string; name: string }[];
}) {
const t = await getTranslations("processes");
const tAssets = await getTranslations("assets");
const tRole = await getTranslations("processRole");
const tCat = await getTranslations("processCategory");
const tc = await getTranslations("common");
const bia = process.bia;
const FORM_ID = "process-edit-form";
const selectClass = "h-9 rounded-md border border-input bg-transparent px-3 text-sm";
const primary = process.processAssets.filter((pa) => pa.role === "PRIMARY");
const secondary = process.processAssets.filter((pa) => pa.role === "SECONDARY");
// Kandidaten für „benötigt": alle anderen Prozesse des Mandanten, noch nicht verknüpft.
const linkedTargets = new Set(process.dependsOn.map((d) => d.target.id));
const depCandidates = processes.filter((p) => p.id !== process.id && !linkedTargets.has(p.id));
const assetChip = (pa: ProcessWithDetail["processAssets"][number]) => (
<span
key={pa.id}
className="inline-flex items-center gap-2 rounded-full border bg-card py-1 pr-2 pl-3 text-sm"
>
<span className="font-medium">{pa.asset.name}</span>
<CiaBadge c={pa.asset.confidentiality} i={pa.asset.integrity} a={pa.asset.availability} />
<form action={unassignAsset.bind(null, process.id, pa.id)} className="flex">
<button
type="submit"
title={tc("remove")}
className="text-muted-foreground hover:text-destructive"
>
<X className="size-3.5" />
</button>
</form>
</span>
);
const jump = [
{ href: "#p-grunddaten", label: t("secMaster") },
{ href: "#p-assets", label: t("secAssets") },
{ href: "#p-deps", label: t("depsSection") },
{ href: "#p-bia", label: t("bia") },
];
return (
<Modal
title={t("editTitle")}
sub={process.name}
headerExtra={
<details className="relative">
<summary className="grid size-8 cursor-pointer list-none place-items-center rounded-md text-muted-foreground hover:bg-muted [&::-webkit-details-marker]:hidden">
<MoreHorizontal className="size-4.5" />
</summary>
<div className="shadow-card absolute right-0 z-20 mt-1 w-52 rounded-xl border bg-card p-1.5">
<form action={deleteProcess.bind(null, process.id)}>
<button
type="submit"
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm text-destructive hover:bg-destructive/10"
>
<Trash2 className="size-4" /> {t("deleteProcess")}
</button>
</form>
</div>
</details>
}
closeHref={`/processes?detail=${process.id}`}
closeLabel={t("close")}
footer={
<>
<Button
variant="outline"
nativeButton={false}
render={<Link href={`/processes?detail=${process.id}`} />}
>
{tc("cancel")}
</Button>
<Button type="submit" form={FORM_ID}>
{tc("save")}
</Button>
</>
}
>
{/* Sprungnavigation */}
<div className="sticky top-0 z-10 flex gap-1.5 border-b bg-card/95 px-5 py-2.5 backdrop-blur">
{jump.map((j) => (
<a
key={j.href}
href={j.href}
className="rounded-full border px-3 py-1 font-heading text-[12px] font-semibold text-muted-foreground hover:bg-secondary hover:text-foreground"
>
{j.label}
</a>
))}
</div>
{/* Die eigentliche <form> ist leer; alle Felder verweisen per form-Attribut
hierauf → ein Speichern-Button, egal wo die Felder stehen. */}
<form id={FORM_ID} action={saveProcessAll.bind(null, process.id)} className="hidden" />
<div className="space-y-6 p-5">
{/* Grunddaten */}
<section id="p-grunddaten" className="scroll-mt-14">
<p className="mb-3 font-heading text-[15px] font-semibold">{t("secMaster")}</p>
<div className="grid gap-4 md:grid-cols-2">
<div>
<Label htmlFor="name">{t("name")}</Label>
<Input
id="name"
name="name"
form={FORM_ID}
required
defaultValue={process.name}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="category">{t("category")}</Label>
<select
id="category"
name="category"
form={FORM_ID}
defaultValue={process.category}
className={`${selectClass} mt-1 w-full`}
>
{(["CORE", "MANAGEMENT", "SUPPORT"] as const).map((v) => (
<option key={v} value={v}>
{tCat(v)}
</option>
))}
</select>
</div>
<div className="md:col-span-2">
<Label htmlFor="description">{t("description")}</Label>
<Textarea
id="description"
name="description"
form={FORM_ID}
rows={2}
defaultValue={process.description ?? ""}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="ownerId">{t("owner")}</Label>
<select
id="ownerId"
name="ownerId"
form={FORM_ID}
defaultValue={process.ownerId ?? ""}
className={`${selectClass} mt-1 w-full`}
>
<option value="">{tc("none")}</option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="deputyOwnerId">{t("deputyOwner")}</Label>
<select
id="deputyOwnerId"
name="deputyOwnerId"
form={FORM_ID}
defaultValue={process.deputyOwnerId ?? ""}
className={`${selectClass} mt-1 w-full`}
>
<option value="">{t("noDeputy")}</option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="parentId">{t("parent")}</Label>
<select
id="parentId"
name="parentId"
form={FORM_ID}
defaultValue={process.parentId ?? ""}
className={`${selectClass} mt-1 w-full`}
>
<option value="">{t("noParent")}</option>
{processes
.filter((p) => p.id !== process.id)
.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
<div className="md:col-span-2">
<Label htmlFor="purpose">{t("purpose")}</Label>
<Textarea
id="purpose"
name="purpose"
form={FORM_ID}
rows={2}
defaultValue={process.purpose ?? ""}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="legalBasis">{t("legalBasis")}</Label>
<Input
id="legalBasis"
name="legalBasis"
form={FORM_ID}
defaultValue={process.legalBasis ?? ""}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="interfaces">{t("interfaces")}</Label>
<Input
id="interfaces"
name="interfaces"
form={FORM_ID}
defaultValue={process.interfaces ?? ""}
className="mt-1"
/>
</div>
<div className="md:col-span-2 flex flex-wrap gap-5">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="dataProtectionRelevant"
form={FORM_ID}
defaultChecked={process.dataProtectionRelevant}
className="size-4 rounded border-input"
/>
{t("dataProtectionRelevant")}
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="prototypeRelevant"
form={FORM_ID}
defaultChecked={process.prototypeRelevant}
className="size-4 rounded border-input"
/>
{t("prototypeRelevant")}
</label>
</div>
{process.catalogCode && (
<input type="hidden" name="catalogCode" value={process.catalogCode} form={FORM_ID} />
)}
</div>
</section>
{/* Assets (inkrementell) */}
<section id="p-assets" className="scroll-mt-14 border-t pt-5 text-sm">
<p className="mb-3 font-heading text-[15px] font-semibold">{t("secAssets")}</p>
<div className="mb-4">
<p className="font-medium">{t("primaryAssets")}</p>
<p className="mb-2 text-xs text-muted-foreground">{t("primaryHint")}</p>
{primary.length === 0 ? (
<p className="text-muted-foreground">{tc("none")}</p>
) : (
<div className="flex flex-wrap gap-2">{primary.map(assetChip)}</div>
)}
</div>
<div>
<p className="font-medium">{t("secondaryAssets")}</p>
<p className="mb-2 text-xs text-muted-foreground">{t("secondaryHint")}</p>
{secondary.length === 0 ? (
<p className="text-muted-foreground">{tc("none")}</p>
) : (
<div className="flex flex-wrap gap-2">{secondary.map(assetChip)}</div>
)}
</div>
{availableAssets.length > 0 && (
<form action={assignAsset.bind(null, process.id)} className="mt-3 flex flex-wrap gap-2">
<select name="assetId" required className={`${selectClass} flex-1`}>
{availableAssets.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
</option>
))}
</select>
<select name="role" defaultValue="SECONDARY" className={selectClass}>
<option value="PRIMARY">{tRole("PRIMARY")}</option>
<option value="SECONDARY">{tRole("SECONDARY")}</option>
</select>
<Button type="submit" variant="secondary" size="sm">
{t("assignAsset")}
</Button>
</form>
)}
</section>
{/* Abhängigkeiten (inkrementell, eigene Forms wie Asset-Zuordnung) */}
<section id="p-deps" className="scroll-mt-14 border-t pt-5 text-sm">
<p className="mb-1 font-heading text-[15px] font-semibold">{t("depsSection")}</p>
<p className="mb-3 text-xs text-muted-foreground">{t("depsRequiresHint")}</p>
<p className="font-medium">↳ {t("depsRequires")}</p>
{process.dependsOn.length === 0 ? (
<p className="mt-1 mb-2 text-muted-foreground">{tc("none")}</p>
) : (
<div className="mt-1.5 mb-3 flex flex-wrap gap-2">
{process.dependsOn.map((d) => (
<span
key={d.id}
className="inline-flex items-center gap-2 rounded-full border bg-card py-1 pr-2 pl-3 text-sm"
>
<span className="font-medium">{d.target.name}</span>
{d.note && <span className="text-muted-foreground">· {d.note}</span>}
<form action={removeProcessDependency.bind(null, d.id)} className="flex">
<button
type="submit"
title={tc("remove")}
className="text-muted-foreground hover:text-destructive"
>
<X className="size-3.5" />
</button>
</form>
</span>
))}
</div>
)}
{depCandidates.length > 0 ? (
<form
action={addProcessDependency.bind(null, process.id)}
className="mt-1 flex flex-wrap gap-2"
>
<select name="targetProcessId" required defaultValue="" className={`${selectClass} flex-1`}>
<option value="" disabled>
{t("depsSelect")}
</option>
{depCandidates.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<Input name="note" placeholder={t("depsNotePlaceholder")} className="flex-1" />
<Button type="submit" variant="secondary" size="sm">
{t("depsAdd")}
</Button>
</form>
) : (
<p className="text-muted-foreground">{tc("none")}</p>
)}
{process.requiredBy.length > 0 && (
<div className="mt-4">
<p className="font-medium">▲ {t("depsRequiredBy")}</p>
<div className="mt-1.5 flex flex-wrap gap-2">
{process.requiredBy.map((d) => (
<Pill key={d.id} tone="info">
{d.source.name}
</Pill>
))}
</div>
</div>
)}
</section>
{/* Business Impact Analyse */}
<section id="p-bia" className="scroll-mt-14 border-t pt-5 text-sm">
<p className="mb-3 font-heading text-[15px] font-semibold">{t("bia")}</p>
<div className="grid grid-cols-3 gap-4">
{(
[
["rtoHours", t("rto"), t("rtoLong"), bia?.rtoHours],
["rpoHours", t("rpo"), t("rpoLong"), bia?.rpoHours],
["mtdHours", t("mtd"), t("mtdLong"), bia?.mtdHours],
] as const
).map(([name, label, hint, value]) => (
<div key={name}>
<Label htmlFor={name} title={hint}>
{label}
</Label>
<Input
id={name}
name={name}
form={FORM_ID}
type="number"
min={0}
defaultValue={value ?? ""}
className="mt-1"
/>
</div>
))}
</div>
<fieldset className="mt-4">
<legend className="font-medium">{t("impact")}</legend>
<div className="mt-2 grid grid-cols-3 gap-4">
{(
[
["impactC", tAssets("confidentiality"), bia?.impactC],
["impactI", tAssets("integrity"), bia?.impactI],
["impactA", tAssets("availability"), bia?.impactA],
] as const
).map(([name, label, value]) => (
<div key={name}>
<Label>{label}</Label>
<div className="mt-1">
<SegmentedRating name={name} defaultValue={value ?? 1} form={FORM_ID} />
</div>
</div>
))}
</div>
</fieldset>
<div className="mt-4">
<Label htmlFor="notes">{t("notes")}</Label>
<Textarea
id="notes"
name="notes"
form={FORM_ID}
rows={3}
defaultValue={bia?.notes ?? ""}
className="mt-1"
/>
</div>
</section>
</div>
</Modal>
);
}
-277
View File
@@ -1,277 +0,0 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Network, Pencil, Plus, Trash2 } from "lucide-react";
import type { Prisma } from "@prisma/client";
import { createProject, deleteProject, updateProject } from "@/server/actions/projects";
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 { SegmentedRating } from "@/components/segmented-rating";
import { CiaBadge, CiaLegend, Pill, Tag } from "@/components/mockup-ui";
import { projectRef, withQuery } from "@/lib/supplier";
import { riskLevel, riskRef, RISK_PILL_TONE } from "@/lib/risk";
export type ProjectAssetDetail = Prisma.AssetGetPayload<{
include: {
projectProfile: true;
owner: { select: { id: true; name: true } };
riskAssets: { include: { risk: { select: { id: true; refNo: true; title: true; score: 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 } } } };
};
}>;
const inputCls = "h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm";
const STATUS_TONE = { GEPLANT: "info", LAUFEND: "warn", ABGESCHLOSSEN: "ok", ABGEBROCHEN: "risk" } as const;
const STATUS_VALUES = ["GEPLANT", "LAUFEND", "ABGESCHLOSSEN", "ABGEBROCHEN"] as const;
/* ─────────────────────── Detail-Cockpit ─────────────────────── */
export async function ProjectDetailModal({ project, canWrite, backHref = "/assets?type=PROJECT" }: { project: ProjectAssetDetail; canWrite: boolean; backHref?: string }) {
const t = await getTranslations("projects");
const ta = await getTranslations("assets");
const tType = await getTranslations("assetType");
const tStatus = await getTranslations("projectStatus");
const tLevel = await getTranslations("riskLevel");
const tRisks = await getTranslations("risks");
const tDep = await getTranslations("dependencies");
const tc = await getTranslations("common");
const p = project.projectProfile!;
return (
<Modal
title={project.name}
sub={t("detailSub")}
headerExtra={
<span className="flex items-center gap-2">
<Tag>{tType(project.type)}</Tag>
<Pill tone={STATUS_TONE[p.status]}>{tStatus(p.status)}</Pill>
</span>
}
closeHref={backHref}
closeLabel={t("close")}
footer={
<>
{canWrite && (
<Button variant="outline" nativeButton={false} render={<Link href={withQuery(backHref, "edit", project.id)} />}>
<Pencil className="size-4" /> {tc("edit")}
</Button>
)}
<Button nativeButton={false} render={<Link href={backHref} />}>{t("close")}</Button>
</>
}
>
<div className="grid gap-5 p-5 md:grid-cols-2">
{/* Stammdaten */}
<div>
<div className="mb-2.5 flex items-center gap-2.5">
<Pill tone="violet">{t("masterPill")}</Pill>
</div>
<div className="rounded-xl border border-l-[3px] border-l-[var(--primary)] bg-[var(--surface-soft)] p-4">
<div className="flex items-center justify-between gap-2">
<b>{projectRef(p.refNo)} · {project.name}</b>
<CiaBadge c={project.confidentiality} i={project.integrity} a={project.availability} labels />
</div>
<div className="mt-1.5 space-y-1 text-[12.5px] leading-relaxed text-muted-foreground">
<div>{t("owner")}: {project.owner?.name ?? tc("none")}</div>
{p.classification && <div>{t("classification")}: {p.classification}</div>}
<div>{t("status")}: {tStatus(p.status)}</div>
<div>{t("isbInvolved")}: {p.isbInvolved ? tc("yes") : tc("no")}</div>
</div>
{p.notes && <p className="mt-2 text-[12.5px] leading-relaxed">{p.notes}</p>}
</div>
<div className="mt-4">
<p className="text-sm font-semibold">{ta("processes")}</p>
{project.processAssets.length === 0 && <p className="mt-1 text-sm text-muted-foreground">{tc("none")}</p>}
<ul className="mt-1.5 space-y-1.5 text-sm">
{project.processAssets.map((pa) => (
<li key={pa.id}>
<Link href={`/processes?detail=${pa.process.id}`} className="hover:underline">{pa.process.name}</Link>
</li>
))}
</ul>
</div>
</div>
{/* Verknüpfte Assets */}
<div>
<div className="mb-2.5 flex items-center gap-2.5">
<Pill tone="info">{ta("depPill")}</Pill>
</div>
{project.relationsFrom.length === 0 && project.relationsTo.length === 0 && (
<p className="text-sm text-muted-foreground">{tc("none")}</p>
)}
{project.relationsFrom.length > 0 && (
<table className="w-full text-sm">
<thead>
<tr><th /><th /><th className="pb-1 text-right font-normal"><CiaLegend /></th></tr>
</thead>
<tbody>
{project.relationsFrom.map((rel) => (
<tr key={rel.id} className="border-b last:border-0">
<td className="py-2.5 pr-2 font-bold">
<Link href={`/assets?detail=${rel.relatedAsset.id}`} className="hover:underline">{rel.relatedAsset.name}</Link>
</td>
<td className="py-2.5 pr-2"><Tag>{tType(rel.relatedAsset.type)}</Tag></td>
<td className="py-2.5 text-right">
<CiaBadge c={rel.relatedAsset.confidentiality} i={rel.relatedAsset.integrity} a={rel.relatedAsset.availability} />
</td>
</tr>
))}
</tbody>
</table>
)}
<Link href="/dependencies" className="mt-3 inline-flex items-center gap-1.5 text-[12.5px] font-semibold text-[var(--info)] hover:underline">
<Network className="size-3.5" /> {tDep("openGraph")} →
</Link>
</div>
</div>
{/* Zugeordnete Risiken */}
<div className="mx-5 mb-5 rounded-xl border border-[var(--band-brd)] bg-[var(--band)] p-4 text-[12.5px] text-[var(--band-text)]">
<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=${project.id}`} />}>
<Plus className="size-3.5" /> {tRisks("createFromAsset")}
</Button>
)}
</div>
{project.riskAssets.length === 0 && <p className="mt-1">{tc("none")}</p>}
<ul className="mt-2 space-y-1.5">
{project.riskAssets.map((ra) => (
<li key={ra.id} className="flex items-center gap-2">
<Link href={`/risks?detail=${ra.risk.id}`} className="font-bold hover:underline">{riskRef(ra.risk.refNo)}</Link>
<Link href={`/risks?detail=${ra.risk.id}`} className="hover:underline">{ra.risk.title}</Link>
<Pill tone={RISK_PILL_TONE[riskLevel(ra.risk.score)]}>{ra.risk.score} · {tLevel(riskLevel(ra.risk.score))}</Pill>
</li>
))}
</ul>
</div>
</Modal>
);
}
/* ─────────────────────── Formular ─────────────────────── */
async function ProjectFields({ project, users, formId }: { project?: ProjectAssetDetail; users: { id: string; name: string }[]; formId?: string }) {
const t = await getTranslations("projects");
const tA = await getTranslations("assets");
const tLevel = await getTranslations("protectionLevel");
const tStatus = await getTranslations("projectStatus");
const f = formId ? { form: formId } : {};
const p = project?.projectProfile;
return (
<div className="grid gap-4 md:grid-cols-2">
<div className="md:col-span-2">
<Label htmlFor="name">{t("name")}</Label>
<Input id="name" name="name" required defaultValue={project?.name} className="mt-1" {...f} />
</div>
<div>
<Label htmlFor="ownerId">{t("owner")}</Label>
<select id="ownerId" name="ownerId" defaultValue={project?.ownerId ?? ""} className={`${inputCls} mt-1`} {...f}>
<option value="">{t("none")}</option>
{users.map((u) => (
<option key={u.id} value={u.id}>{u.name}</option>
))}
</select>
</div>
<div>
<Label htmlFor="status">{t("status")}</Label>
<select id="status" name="status" defaultValue={p?.status ?? "GEPLANT"} className={`${inputCls} mt-1`} {...f}>
{STATUS_VALUES.map((s) => (
<option key={s} value={s}>{tStatus(s)}</option>
))}
</select>
</div>
<div className="md:col-span-2">
<Label htmlFor="classification">{t("classification")}</Label>
<Input id="classification" name="classification" defaultValue={p?.classification ?? ""} className="mt-1" {...f} />
</div>
<fieldset className="md:col-span-2">
<legend className="text-sm font-medium">{t("protection")} (1–4)</legend>
<div className="mt-2 grid grid-cols-3 gap-4">
{(
[
["confidentiality", tA("confidentiality")],
["integrity", tA("integrity")],
["availability", tA("availability")],
] as const
).map(([n, label]) => (
<div key={n}>
<Label>{label}</Label>
<div className="mt-1">
<SegmentedRating name={n} defaultValue={(project?.[n] as number) ?? 1} low={tLevel("1")} high={tLevel("4")} form={formId} />
</div>
</div>
))}
</div>
</fieldset>
<label className="flex items-center gap-2 text-sm md:col-span-2">
<input type="checkbox" name="isbInvolved" defaultChecked={p?.isbInvolved} {...f} /> {t("isbInvolved")}
</label>
<div className="md:col-span-2">
<Label htmlFor="notes">{t("notes")}</Label>
<Textarea id="notes" name="notes" rows={2} defaultValue={p?.notes ?? ""} className="mt-1" {...f} />
</div>
</div>
);
}
export async function ProjectCreateModal({ users }: { users: { id: string; name: string }[] }) {
const t = await getTranslations("projects");
const tc = await getTranslations("common");
return (
<Modal title={t("createTitle")} closeHref="/assets?type=PROJECT" closeLabel={t("close")}>
<form action={createProject} className="space-y-4 p-5">
<ProjectFields users={users} />
<div className="flex gap-2 pt-1">
<Button type="submit">{tc("save")}</Button>
<Button variant="outline" nativeButton={false} render={<Link href="/assets?type=PROJECT" />}>{tc("cancel")}</Button>
</div>
</form>
</Modal>
);
}
export async function ProjectEditModal({ project, users, backHref = "/assets?type=PROJECT" }: { project: ProjectAssetDetail; users: { id: string; name: string }[]; backHref?: string }) {
const t = await getTranslations("projects");
const tc = await getTranslations("common");
const FORM = "project-edit";
const detailHref = withQuery(backHref, "detail", project.id);
return (
<Modal
title={t("editTitle")}
sub={project.name}
headerExtra={
<details className="relative">
<summary className="grid size-8 cursor-pointer list-none place-items-center rounded-md text-muted-foreground hover:bg-muted [&::-webkit-details-marker]:hidden"><Trash2 className="size-4" /></summary>
<div className="shadow-card absolute right-0 z-20 mt-1 w-48 rounded-xl border bg-card p-1.5">
<form action={deleteProject.bind(null, project.id, backHref)}>
<button type="submit" className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm text-destructive hover:bg-destructive/10"><Trash2 className="size-4" /> {tc("delete")}</button>
</form>
</div>
</details>
}
closeHref={detailHref}
closeLabel={t("close")}
footer={
<>
<Button variant="outline" nativeButton={false} render={<Link href={detailHref} />}>{tc("cancel")}</Button>
<Button type="submit" form={FORM}>{tc("save")}</Button>
</>
}
>
<form id={FORM} action={updateProject.bind(null, project.id)} className="hidden">
<input type="hidden" name="returnTo" value={backHref} />
</form>
<div className="p-5">
<ProjectFields project={project} users={users} formId={FORM} />
</div>
</Modal>
);
}
-849
View File
@@ -1,849 +0,0 @@
import Link from "next/link";
import { getFormatter, getTranslations } from "next-intl/server";
import { ArrowRight, Check, Pencil, Plus, Trash2, X } from "lucide-react";
import type { Prisma } from "@prisma/client";
import {
addRiskAsset,
createRisk,
deleteRisk,
removeRiskAsset,
submitRiskForReview,
updateRisk,
} from "@/server/actions/risks";
import { ObjectReview } from "@/components/object-review";
import { adoptStandardMeasure, acceptRisk } from "@/server/actions/risk-catalog";
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";
import { Textarea } from "@/components/ui/textarea";
import { Modal } from "@/components/modal";
import { CiaBadge, CiaLegend, Pill, Tag } from "@/components/mockup-ui";
import { riskLevel, riskRef, RISK_PILL_TONE, RISK_SCALE_GRADIENT } from "@/lib/risk";
import { measureRef, MEASURE_STATUS_TONE } from "@/lib/measure";
export type RiskWithDetail = Prisma.RiskGetPayload<{
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;
};
};
};
};
riskMeasures: {
include: {
measure: { select: { id: true; refNo: true; title: true; status: true } };
};
};
};
}>;
const SCALE = [1, 2, 3, 4, 5] as const;
const TREATMENTS = ["AVOID", "MITIGATE", "TRANSFER", "ACCEPT"] as const;
const STATUSES = ["OPEN", "IN_TREATMENT", "ACCEPTED", "CLOSED"] as const;
const STATUS_TONE = {
OPEN: "risk",
IN_TREATMENT: "warn",
ACCEPTED: "info",
CLOSED: "mut",
} as const;
async function ScorePill({ score }: { score: number }) {
const tLevel = await getTranslations("riskLevel");
const format = await getFormatter();
const level = riskLevel(score);
return (
<Pill tone={RISK_PILL_TONE[level]}>
{format.number(score, { maximumFractionDigits: 2 })} · {tLevel(level)}
</Pill>
);
}
/** Bewertungs-Karte: große Score-Zahl, darunter Wahrscheinlichkeit × Schaden + Level-Chip. */
async function RatingCard({
title,
likelihood,
impact,
score,
accent,
}: {
title: string;
likelihood: number | null;
impact: number | null;
score: number | null;
accent: "gross" | "residual";
}) {
const t = await getTranslations("risks");
const format = await getFormatter();
const num = (v: number) => format.number(v, { maximumFractionDigits: 2 });
const border = accent === "gross" ? "border-l-[var(--risk)]" : "border-l-[var(--warn)]";
return (
<div className={`rounded-xl border border-l-[3px] bg-[var(--elevated)] p-4 ${border}`}>
<div className="text-[11px] font-bold tracking-wide text-muted-foreground uppercase">
{title}
</div>
{score != null && likelihood != null && impact != null ? (
<>
<div className="mt-1 flex items-baseline gap-2">
<span className="font-heading text-3xl font-bold leading-none text-foreground">
{num(score)}
</span>
<ScorePill score={score} />
</div>
<div className="mt-1.5 text-[12px] text-muted-foreground">
{t("likelihoodShort")} {num(likelihood)} × {t("damageShort")} {num(impact)}
</div>
</>
) : (
<p className="mt-1.5 text-[12px] text-muted-foreground">{t("noMeasures")}</p>
)}
{accent === "residual" && (
<p className="mt-2 text-[11.5px] text-muted-foreground">{t("residualAuto")}</p>
)}
</div>
);
}
/** Kräftige Farbskala grün→rot mit Markern für Brutto (weiß) und Rest (dunkel). SPEC §4.2. */
function RiskScoreScale({ gross, residual }: { gross: number; residual: number | null }) {
// 5×5 → Score 1..25; Position linear auf der Skala
const pos = (v: number) => `${((Math.min(25, Math.max(1, v)) - 1) / 24) * 100}%`;
return (
<div className="pt-1">
<div
className="relative h-3.5 rounded-lg ring-1 ring-white/10"
style={{ background: RISK_SCALE_GRADIENT }}
>
<span
className="absolute -top-1.5 h-6.5 w-[4px] -translate-x-1/2 rounded-sm bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.5)]"
style={{ left: pos(gross) }}
title={`Brutto ${gross}`}
/>
{residual != null && (
<span
className="absolute -top-1.5 h-6.5 w-[4px] -translate-x-1/2 rounded-sm bg-[var(--bg-0)] shadow-[0_0_0_1px_rgba(255,255,255,0.7)]"
style={{ left: pos(residual) }}
title={`Rest ${residual}`}
/>
)}
</div>
<div className="mt-2 flex items-center gap-4 text-[11px] text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<i className="inline-block h-3 w-[4px] rounded-sm bg-white ring-1 ring-black/40" /> Brutto
</span>
<span className="inline-flex items-center gap-1.5">
<i className="inline-block h-3 w-[4px] rounded-sm bg-[var(--bg-0)] ring-1 ring-white/60" /> Rest
</span>
</div>
</div>
);
}
/** Read-only-Risiko-Detail als Popup (SPEC §4.2 Risiko-Detailansicht). */
export async function RiskDetailModal({
risk,
canWrite,
}: {
risk: RiskWithDetail;
canWrite: boolean;
}) {
const t = await getTranslations("risks");
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 (
<Modal
title={t("detailHeading", { ref: riskRef(risk.refNo), name: risk.title })}
sub={t("detailSub")}
headerExtra={
<span className="flex items-center gap-2">
<Pill tone={STATUS_TONE[risk.status]}>{tStatus(risk.status)}</Pill>
<ScorePill score={risk.score} />
</span>
}
closeHref="/risks"
closeLabel={t("close")}
footer={
<>
{canWrite && (
<Button
variant="outline"
nativeButton={false}
render={<Link href={`/risks?edit=${risk.id}`} />}
>
<Pencil className="size-4" /> {tc("edit")}
</Button>
)}
<Button nativeButton={false} render={<Link href="/risks" />}>
{t("close")}
</Button>
</>
}
>
<div className="grid gap-5 p-5 md:grid-cols-2">
<div>
<div className="rounded-xl border border-l-[3px] border-l-[var(--primary)] bg-[var(--surface-soft)] p-4">
<b>{risk.title}</b>
{risk.description && (
<p className="mt-1.5 text-[12.5px] leading-relaxed">{risk.description}</p>
)}
<dl className="mt-3 grid grid-cols-[7.5rem_1fr] gap-1.5 text-[12.5px]">
<dt className="text-muted-foreground">{t("threat")}</dt>
<dd>{risk.threat ?? tc("none")}</dd>
<dt className="text-muted-foreground">{t("vulnerability")}</dt>
<dd>{risk.vulnerability ?? tc("none")}</dd>
<dt className="text-muted-foreground">{t("owner")}</dt>
<dd>{risk.owner?.name ?? tc("none")}</dd>
<dt className="text-muted-foreground">{t("treatment")}</dt>
<dd>
<Tag>{tTreat(risk.treatment)}</Tag>
</dd>
<dt className="text-muted-foreground">{t("process")}</dt>
<dd>
{risk.process ? (
<Link href={`/processes?detail=${risk.process.id}`} className="hover:underline">
{risk.process.name}
</Link>
) : (
tc("none")
)}
</dd>
</dl>
</div>
</div>
<div>
<div className="mb-2.5 flex items-center gap-2.5">
<Pill tone="info">{t("affectedAssets")}</Pill>
<span className="text-[12.5px] text-muted-foreground">{t("affectedNote")}</span>
</div>
{risk.riskAssets.length === 0 && (
<p className="text-sm text-muted-foreground">{t("noAssets")}</p>
)}
<table className="w-full text-sm">
<thead>
<tr>
<th />
<th />
<th className="pb-1 text-right font-normal">
<CiaLegend />
</th>
</tr>
</thead>
<tbody>
{risk.riskAssets.map((ra) => (
<tr key={ra.id} className="border-b last:border-0">
<td className="py-2.5 pr-2 font-bold">
<Link href={`/assets?detail=${ra.asset.id}`} className="hover:underline">
{ra.asset.name}
</Link>
</td>
<td className="py-2.5 pr-2">
<Tag>{tType(ra.asset.type)}</Tag>
</td>
<td className="py-2.5 text-right">
<CiaBadge
c={ra.asset.confidentiality}
i={ra.asset.integrity}
a={ra.asset.availability}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Brutto → Rest-Risiko: Farbskala oben, darunter zwei Karten mit Pfeil */}
<div className="mx-5 rounded-xl border border-[var(--band-brd)] bg-[var(--band)] p-4">
<RiskScoreScale gross={risk.score} residual={risk.residualScore} />
<div className="mt-4 grid items-center gap-3 sm:grid-cols-[1fr_auto_1fr]">
<RatingCard
title={t("gross")}
likelihood={risk.likelihood}
impact={risk.impact}
score={risk.score}
accent="gross"
/>
<ArrowRight className="mx-auto size-5 rotate-90 text-muted-foreground sm:rotate-0" />
<RatingCard
title={t("residual")}
likelihood={risk.residualLikelihood}
impact={risk.residualImpact}
score={risk.residualScore}
accent="residual"
/>
</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>
{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) && (
<ReductionLabel l={rm.reductionLikelihood} i={rm.reductionImpact} />
)}
</li>
))}
</ul>
</div>
{/* Generisches Objekt-Review (Story A3-1): Risiko zur Validierung einreichen / validieren. */}
<div className="px-5 pb-5">
<ObjectReview
entityType="risk"
entityId={risk.id}
entityRef={riskRef(risk.refNo)}
title={`Validierung: ${riskRef(risk.refNo)} — ${risk.title}`}
submit={submitRiskForReview.bind(null, risk.id)}
canSubmit={canWrite}
/>
</div>
{/* Standardmaßnahme & Risikoakzeptanz (Story A6-2) — additiv, ergänzt A3-Review. */}
{canWrite && (
<div className="grid gap-3 px-5 pb-5 md:grid-cols-2">
{risk.catalogCode && (
<div className="rounded-xl border bg-[var(--surface-soft)] p-3">
<p className="text-[13px] font-semibold">Standardmaßnahme</p>
<p className="mt-0.5 text-[11.5px] text-muted-foreground">Aus dem Katalog ({risk.catalogCode}) eine mit diesem Risiko verknüpfte Maßnahme anlegen — erscheint unten bei den Maßnahmen.</p>
<form action={adoptStandardMeasure.bind(null, risk.id)} className="mt-2">
<Button type="submit" size="sm" variant="outline"><Plus className="size-3.5" /> Standardmaßnahme übernehmen</Button>
</form>
</div>
)}
<div className="rounded-xl border bg-[var(--surface-soft)] p-3">
<p className="text-[13px] font-semibold">Risikoakzeptanz (VA-09)</p>
{risk.acceptanceRationale ? (
<p className="mt-1 text-[11.5px] text-muted-foreground"><Check className="mr-1 inline size-3.5 text-[var(--ok)]" />Dokumentiert akzeptiert: {risk.acceptanceRationale}</p>
) : (
<>
<p className="mt-0.5 text-[11.5px] text-muted-foreground">
{(risk.residualScore ?? risk.score) > 9
? "Rest-/Risikowert über der Akzeptanzschwelle — Begründung erforderlich."
: "Optional: Akzeptanz dokumentieren."}
</p>
<form action={acceptRisk.bind(null, risk.id)} className="mt-2 space-y-2">
<textarea name="rationale" rows={2} placeholder="Begründung / Managemententscheidung" className="w-full rounded-md border bg-background p-2 text-[12px]" />
<Button type="submit" size="sm" variant="outline">Risiko akzeptieren</Button>
</form>
</>
)}
</div>
</div>
)}
</Modal>
);
}
/** Bearbeiten als Popup: Bewertung, betroffene Assets, Maßnahmen, Löschen. */
export async function RiskEditModal({
risk,
users,
processes,
availableAssets,
availableMeasures,
threats,
vulnerabilities,
}: {
risk: RiskWithDetail;
users: { id: string; name: string }[];
processes: { id: string; name: string }[];
availableAssets: { id: string; name: string }[];
availableMeasures: { id: string; refNo: number; title: string }[];
threats: string[];
vulnerabilities: string[];
}) {
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("editTitle")}
sub={`${riskRef(risk.refNo)} · ${risk.title}`}
closeHref={`/risks?detail=${risk.id}`}
closeLabel={t("close")}
>
<div className="grid gap-6 p-5 md:grid-cols-2">
<form
key={risk.updatedAt.toISOString()}
action={updateRisk.bind(null, risk.id)}
className="space-y-4"
>
<RiskFields
risk={risk}
users={users}
processes={processes}
threats={threats}
vulnerabilities={vulnerabilities}
selectClass={selectClass}
/>
<Button type="submit">{tc("save")}</Button>
</form>
<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>
{risk.riskAssets.length === 0 && <p>{tc("none")}</p>}
<ul className="space-y-1.5">
{risk.riskAssets.map((ra) => (
<li key={ra.id} className="flex items-center gap-2">
{ra.asset.name}
<form action={removeRiskAsset.bind(null, risk.id, ra.id)}>
<button
type="submit"
title={tc("remove")}
className="text-muted-foreground hover:text-destructive"
>
<X className="size-3.5" />
</button>
</form>
</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>
</div>
</div>
{/* Maßnahmen → bestimmen das Rest-Risiko (volle Breite unter den Stammdaten) */}
<div className="border-t p-5 text-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="font-heading text-[15px] font-semibold">{t("measures")}</p>
<p className="text-xs text-muted-foreground">{t("residualAuto")}</p>
</div>
<div className="flex gap-2">
{/* Aufklappbare Formulare per <details> — serverseitig, kein Client-State */}
{availableMeasures.length > 0 && (
<details className="relative">
<summary className="bg-grad-soft inline-flex h-8 cursor-pointer list-none items-center gap-1.5 rounded-lg px-2.5 font-heading text-sm font-semibold text-white select-none hover:opacity-90 [&::-webkit-details-marker]:hidden">
<Plus className="size-4" /> {t("addExistingBtn")}
</summary>
<form
action={linkMeasureToRisk.bind(null, risk.id)}
className="shadow-card absolute right-0 z-10 mt-2 w-80 space-y-2 rounded-xl border bg-card 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>
<ReductionInputs />
<Button type="submit" variant="secondary" size="sm">
{t("linkMeasure")}
</Button>
</form>
</details>
)}
<details className="relative">
<summary className="inline-flex h-8 cursor-pointer list-none items-center gap-1.5 rounded-lg border border-border bg-background px-2.5 font-heading text-sm font-semibold select-none hover:bg-muted [&::-webkit-details-marker]:hidden">
<Plus className="size-4" /> {t("createNewBtn")}
</summary>
<form
action={createMeasureForRisk.bind(null, risk.id)}
className="shadow-card absolute right-0 z-10 mt-2 w-80 space-y-2 rounded-xl border bg-card p-3"
>
<p className="text-xs font-semibold">{t("newMeasure")}</p>
<Input name="title" required placeholder={t("measureTitle")} />
<ReductionInputs />
<Button type="submit" variant="secondary" size="sm">
{tc("add")}
</Button>
</form>
</details>
</div>
</div>
{risk.riskMeasures.length === 0 && (
<p className="mt-3 text-muted-foreground">{tc("none")}</p>
)}
{risk.riskMeasures.length > 0 && (
<div className="mt-3 overflow-hidden rounded-xl border">
<div className="grid grid-cols-[7rem_1fr_11rem_11rem_auto_auto] items-center gap-x-3 border-b bg-muted/60 px-3 py-2 text-[11px] font-bold tracking-[.04em] text-muted-foreground uppercase max-md:hidden">
<span>{t("id")}</span>
<span>{t("measureCol")}</span>
<span>{t("reductionL")}</span>
<span>{t("reductionI")}</span>
<span />
<span />
</div>
{risk.riskMeasures.map((rm) => (
<form
key={rm.id + "-" + rm.reductionLikelihood + "-" + rm.reductionImpact}
action={linkMeasureToRisk.bind(null, risk.id)}
className="grid grid-cols-1 items-center gap-2 border-b px-3 py-2.5 last:border-0 md:grid-cols-[7rem_1fr_11rem_11rem_auto_auto] md:gap-x-3"
>
<input type="hidden" name="measureId" value={rm.measureId} />
<b>{measureRef(rm.measure.refNo)}</b>
<span className="min-w-0 truncate" title={rm.measure.title}>
{rm.measure.title}
</span>
<Input
name="reductionLikelihood"
type="number"
min={0}
max={4}
step={0.05}
defaultValue={rm.reductionLikelihood}
aria-label={t("reductionL")}
className="h-8"
/>
<Input
name="reductionImpact"
type="number"
min={0}
max={4}
step={0.05}
defaultValue={rm.reductionImpact}
aria-label={t("reductionI")}
className="h-8"
/>
<Button type="submit" variant="secondary" size="icon-sm" title={tc("save")}>
<Check className="size-3.5" />
</Button>
<Button
type="submit"
variant="ghost"
size="icon-sm"
title={tc("remove")}
formAction={unlinkMeasureFromRisk.bind(null, risk.id, rm.id)}
className="text-muted-foreground hover:text-destructive"
>
<X className="size-3.5" />
</Button>
</form>
))}
</div>
)}
</div>
<div className="border-t p-5">
<form action={deleteRisk.bind(null, risk.id)}>
<Button type="submit" variant="destructive" size="sm">
<Trash2 className="size-4" /> {tc("delete")}
</Button>
</form>
</div>
</Modal>
);
}
/** Anzeige einer Minderung mit lokalisierten Dezimalzahlen. */
async function ReductionLabel({ l, i }: { l: number; i: number }) {
const t = await getTranslations("risks");
const format = await getFormatter();
const num = (v: number) => format.number(v, { maximumFractionDigits: 2 });
return (
<span className="text-xs text-muted-foreground">
{t("reduction")}: −{num(l)} {t("likelihoodShort")} / −{num(i)} {t("damageShort")}
</span>
);
}
/** Minderungs-Eingaben (dezimal, 0,00–4,00) für Maßnahmen-Verknüpfungen. */
async function ReductionInputs({
defaultL = 0,
defaultI = 0,
}: {
defaultL?: number;
defaultI?: number;
}) {
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>
<Input
name="reductionLikelihood"
type="number"
min={0}
max={4}
step={0.05}
defaultValue={defaultL}
className="h-9"
/>
</div>
<div>
<label className="text-[11px] text-muted-foreground">{t("reductionI")}</label>
<Input
name="reductionImpact"
type="number"
min={0}
max={4}
step={0.05}
defaultValue={defaultI}
className="h-9"
/>
</div>
</div>
);
}
/** Formularfelder (Anlegen + Bearbeiten teilen sich das Markup). */
async function RiskFields({
risk,
users,
processes,
threats,
vulnerabilities,
selectClass,
}: {
risk?: RiskWithDetail;
users: { id: string; name: string }[];
processes: { id: string; name: string }[];
threats: string[];
vulnerabilities: string[];
selectClass: string;
}) {
const t = await getTranslations("risks");
const tTreat = await getTranslations("riskTreatment");
const tStatus = await getTranslations("riskStatus");
const tc = await getTranslations("common");
return (
<>
<div>
<Label htmlFor="title">{t("titleField")}</Label>
<Input id="title" name="title" required defaultValue={risk?.title} className="mt-1" />
</div>
<div>
<Label htmlFor="description">{t("description")}</Label>
<Textarea
id="description"
name="description"
rows={2}
defaultValue={risk?.description ?? ""}
className="mt-1"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="threat">{t("threat")}</Label>
<Input
id="threat"
name="threat"
list="threat-catalog"
placeholder={t("catalogHint")}
defaultValue={risk?.threat ?? ""}
className="mt-1"
/>
<datalist id="threat-catalog">
{threats.map((name) => (
<option key={name} value={name} />
))}
</datalist>
</div>
<div>
<Label htmlFor="vulnerability">{t("vulnerability")}</Label>
<Input
id="vulnerability"
name="vulnerability"
list="vulnerability-catalog"
placeholder={t("catalogHint")}
defaultValue={risk?.vulnerability ?? ""}
className="mt-1"
/>
<datalist id="vulnerability-catalog">
{vulnerabilities.map((name) => (
<option key={name} value={name} />
))}
</datalist>
</div>
<div>
<Label htmlFor="likelihood">{t("likelihood")}</Label>
<select
id="likelihood"
name="likelihood"
defaultValue={risk?.likelihood ?? 3}
className={`${selectClass} mt-1 w-full`}
>
{SCALE.map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="impact">{t("impact")}</Label>
<select
id="impact"
name="impact"
defaultValue={risk?.impact ?? 3}
className={`${selectClass} mt-1 w-full`}
>
{SCALE.map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="treatment">{t("treatment")}</Label>
<select
id="treatment"
name="treatment"
defaultValue={risk?.treatment ?? "MITIGATE"}
className={`${selectClass} mt-1 w-full`}
>
{TREATMENTS.map((v) => (
<option key={v} value={v}>
{tTreat(v)}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="status">{t("status")}</Label>
<select
id="status"
name="status"
defaultValue={risk?.status ?? "OPEN"}
className={`${selectClass} mt-1 w-full`}
>
{STATUSES.map((v) => (
<option key={v} value={v}>
{tStatus(v)}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="ownerId">{t("owner")}</Label>
<select
id="ownerId"
name="ownerId"
defaultValue={risk?.ownerId ?? ""}
className={`${selectClass} mt-1 w-full`}
>
<option value="">{tc("none")}</option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="processId">{t("process")}</Label>
<select
id="processId"
name="processId"
defaultValue={risk?.processId ?? ""}
className={`${selectClass} mt-1 w-full`}
>
<option value="">{tc("none")}</option>
{processes.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
</div>
</>
);
}
/** Anlegen als Popup — optional mit vorverknüpftem Asset (aus dem Asset-Popup). */
export async function RiskCreateModal({
users,
processes,
preselectedAsset,
threats,
vulnerabilities,
}: {
users: { id: string; name: string }[];
processes: { id: string; name: string }[];
preselectedAsset?: { id: string; name: string } | null;
threats: string[];
vulnerabilities: string[];
}) {
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")}
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}
threats={threats}
vulnerabilities={vulnerabilities}
selectClass={selectClass}
/>
<div className="flex gap-2 pt-1">
<Button type="submit">{tc("save")}</Button>
<Button variant="outline" nativeButton={false} render={<Link href="/risks" />}>
{tc("cancel")}
</Button>
</div>
</form>
</Modal>
);
}
+23 -3
View File
@@ -16,7 +16,27 @@ export interface ManagedRole { id: string; key: string; name: string; isStandard
const createInit: RoleFormState = { status: "idle" };
/** Permission-Checkboxen, gruppiert nach Ressourcen-Präfix (asset:*, risk:* …). */
/** Anzeigenamen der Permission-Gruppen (Ressourcen-Präfix vor dem Doppelpunkt). */
const GROUP_LABEL: Record<string, string> = {
tenant: "Mandant",
user: "Benutzer",
role: "Rollen",
audit: "Audit-Trail",
customer: "Kunden",
site: "Objekte",
team: "Teams",
work_order: "Aufträge",
import: "Auftragsimport",
field: "Einsatz (Mobile)",
report: "Berichte",
emergency: "Notdienst",
document: "Dokumente",
notification: "Benachrichtigungen",
settings: "Vorlagen",
lotse: "Lotse (KI)",
};
/** Permission-Checkboxen, gruppiert nach Ressourcen-Präfix (customer:*, work_order:* …). */
function PermissionChecks({ allPermissions, selected, idPrefix }: { allPermissions: string[]; selected: Set<string>; idPrefix: string }) {
const groups = new Map<string, string[]>();
for (const p of allPermissions) {
@@ -28,7 +48,7 @@ function PermissionChecks({ allPermissions, selected, idPrefix }: { allPermissio
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{[...groups.entries()].map(([g, perms]) => (
<div key={g} className="rounded-lg border bg-muted/40 p-2.5">
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">{g}</p>
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">{GROUP_LABEL[g] ?? g}</p>
<div className="space-y-1">
{perms.map((p) => (
<label key={p} htmlFor={`${idPrefix}-${p}`} className="flex items-center gap-1.5 text-[12.5px]">
@@ -57,7 +77,7 @@ export function CreateRoleForm({ allPermissions, closeHref }: { allPermissions:
<form action={action} className="space-y-3">
<div className="max-w-sm">
<Label htmlFor="nr-name">Rollenname *</Label>
<Input id="nr-name" name="name" required className="mt-1" placeholder="z. B. Auditor (extern)" />
<Input id="nr-name" name="name" required className="mt-1" placeholder="z. B. Disposition" />
</div>
<div>
<Label>Berechtigungen</Label>
-67
View File
@@ -1,67 +0,0 @@
"use client";
import { useState } from "react";
import { cn } from "@/lib/utils";
// Ampel-Farblogik Stufe 1–4: Grün → Gelb → Orange → Rot
const LEVEL_STYLE: Record<number, { on: string; border: string }> = {
1: { on: "bg-[#2e9e6b] text-white border-transparent", border: "border-[#2e9e6b]" },
2: { on: "bg-[#e5b000] text-[#3b3b3a] border-transparent", border: "border-[#e5b000]" },
3: { on: "bg-[#e07d2e] text-white border-transparent", border: "border-[#e07d2e]" },
4: { on: "bg-[#d64c4c] text-white border-transparent", border: "border-[#d64c4c]" },
};
/**
* 1–4 anklickbare, farbige Stufen-Buttons mit genau einem aktiven Wert.
* Der Wert wird in einem Hidden-Input gehalten, damit die Komponente in
* server-gerenderten Formularen (Server Actions) funktioniert.
*/
export function SegmentedRating({
name,
defaultValue = 1,
low,
high,
form,
}: {
name: string;
defaultValue?: number;
low?: string;
high?: string;
// HTML form-Association: erlaubt das Feld außerhalb des <form> zu platzieren
form?: string;
}) {
const [value, setValue] = useState(defaultValue);
return (
<div>
<input type="hidden" name={name} value={value} form={form} />
<div className="flex gap-1.5">
{[1, 2, 3, 4].map((level) => {
const style = LEVEL_STYLE[level];
const active = value === level;
return (
<button
key={level}
type="button"
onClick={() => setValue(level)}
aria-pressed={active}
className={cn(
"flex-1 rounded-[9px] border py-2 font-heading text-[13px] font-bold transition-colors",
style.border,
active ? style.on : "bg-white text-muted-foreground hover:bg-muted/40"
)}
>
{level}
</button>
);
})}
</div>
{(low || high) && (
<div className="mt-1.5 flex justify-between text-[10.5px] text-muted-foreground">
<span>{low}</span>
<span>{high}</span>
</div>
)}
</div>
);
}
-373
View File
@@ -1,373 +0,0 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Network, Pencil, Plus, Trash2 } from "lucide-react";
import type { Prisma } from "@prisma/client";
import {
addRaci,
createService,
cycleRaci,
deleteRaci,
deleteService,
updateService,
} from "@/server/actions/services";
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 { SegmentedRating } from "@/components/segmented-rating";
import { CiaBadge, CiaLegend, CriticalityPill, Pill, Tag } from "@/components/mockup-ui";
import { serviceRef, withQuery } from "@/lib/supplier";
import { riskLevel, riskRef, RISK_PILL_TONE } from "@/lib/risk";
import { ISA_CONTROLS } from "@/lib/isa-controls";
export type ServiceAssetDetail = Prisma.AssetGetPayload<{
include: {
serviceProfile: { include: { provider: { select: { id: true; name: true } } } };
raci: true;
riskAssets: { include: { risk: { select: { id: true; refNo: true; title: true; score: 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 } } } };
};
}>;
const inputCls = "h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm";
const RACI_TONE = { PROVIDER: "info", US: "violet", SHARED: "warn" } as const;
/* ─────────────────────── Detail-Cockpit ─────────────────────── */
export async function ServiceDetailModal({ service, canWrite, backHref = "/suppliers?tab=services" }: { service: ServiceAssetDetail; canWrite: boolean; backHref?: string }) {
const t = await getTranslations("services");
const ta = await getTranslations("assets");
const tParty = await getTranslations("raciParty");
const tCrit = await getTranslations("criticality");
const tType = await getTranslations("assetType");
const tLevel = await getTranslations("riskLevel");
const tRisks = await getTranslations("risks");
const tDep = await getTranslations("dependencies");
const tc = await getTranslations("common");
const tp = await getTranslations("processes");
const p = service.serviceProfile!;
return (
<Modal
title={service.name}
sub={t("detailSub")}
headerExtra={
<span className="flex items-center gap-2">
<Tag>{tType(service.type)}</Tag>
<CriticalityPill level={p.criticality} label={tCrit(String(p.criticality))} />
</span>
}
closeHref={backHref}
closeLabel={tp("close")}
footer={
<>
{canWrite && (
<Button variant="outline" nativeButton={false} render={<Link href={withQuery(backHref, "edit", service.id)} />}>
<Pencil className="size-4" /> {tc("edit")}
</Button>
)}
<Button nativeButton={false} render={<Link href={backHref} />}>{tp("close")}</Button>
</>
}
>
<div className="grid gap-5 p-5 md:grid-cols-2">
{/* Stammdaten — violette Karte wie beim Asset-Detail */}
<div>
<div className="mb-2.5 flex items-center gap-2.5">
<Pill tone="violet">{ta("masterPill")}</Pill>
<span className="text-[12.5px] text-muted-foreground">{ta("masterNote")}</span>
</div>
<div className="rounded-xl border border-l-[3px] border-l-[var(--primary)] bg-[var(--surface-soft)] p-4">
<div className="flex items-center justify-between gap-2">
<b>{serviceRef(p.refNo)} · {service.name}</b>
<CiaBadge c={service.confidentiality} i={service.integrity} a={service.availability} labels />
</div>
<div className="mt-1.5 text-[12.5px] leading-relaxed text-muted-foreground">
{t("provider")}:{" "}
{p.provider ? (
<Link href={`/suppliers?detail=${p.provider.id}`} className="hover:underline">{p.provider.name}</Link>
) : (
p.internal ? t("internal") : t("noProvider")
)}
{" · "}
{t("criticality")}: {tCrit(String(p.criticality))}
</div>
{p.notes && <p className="mt-2 text-[12.5px] leading-relaxed">{p.notes}</p>}
</div>
<div className="mt-4">
<p className="text-sm font-semibold">{ta("processes")}</p>
{service.processAssets.length === 0 && <p className="mt-1 text-sm text-muted-foreground">{tc("none")}</p>}
<ul className="mt-1.5 space-y-1.5 text-sm">
{service.processAssets.map((pa) => (
<li key={pa.id}>
<Link href={`/processes?detail=${pa.process.id}`} className="hover:underline">{pa.process.name}</Link>
</li>
))}
</ul>
</div>
</div>
{/* Verknüpfte Assets — Tabelle wie beim Asset-Detail */}
<div>
<div className="mb-2.5 flex items-center gap-2.5">
<Pill tone="info">{ta("depPill")}</Pill>
<span className="text-[12.5px] text-muted-foreground">{ta("depNote")}</span>
</div>
{service.relationsFrom.length === 0 && service.relationsTo.length === 0 && (
<p className="text-sm text-muted-foreground">{tc("none")}</p>
)}
{service.relationsFrom.length > 0 && (
<table className="w-full text-sm">
<thead>
<tr>
<th />
<th />
<th className="pb-1 text-right font-normal"><CiaLegend /></th>
</tr>
</thead>
<tbody>
{service.relationsFrom.map((rel) => (
<tr key={rel.id} className="border-b last:border-0">
<td className="py-2.5 pr-2 font-bold">
<Link href={`/assets?detail=${rel.relatedAsset.id}`} className="hover:underline">{rel.relatedAsset.name}</Link>
</td>
<td className="py-2.5 pr-2"><Tag>{tType(rel.relatedAsset.type)}</Tag></td>
<td className="py-2.5 text-right">
<CiaBadge c={rel.relatedAsset.confidentiality} i={rel.relatedAsset.integrity} a={rel.relatedAsset.availability} />
</td>
</tr>
))}
</tbody>
</table>
)}
{service.relationsTo.length > 0 && (
<>
<p className="mt-3 text-[12.5px] text-muted-foreground">{ta("relationReverseHint")}</p>
<ul className="mt-1 space-y-1 text-sm">
{service.relationsTo.map((rel) => (
<li key={rel.id}>
<Link href={`/assets?detail=${rel.asset.id}`} className="hover:underline">{rel.asset.name}</Link>
</li>
))}
</ul>
</>
)}
<Link href="/dependencies" className="mt-3 inline-flex items-center gap-1.5 text-[12.5px] font-semibold text-[var(--info)] hover:underline">
<Network className="size-3.5" /> {tDep("openGraph")} →
</Link>
</div>
</div>
{/* Zugeordnete Risiken — Band wie beim Asset-Detail */}
<div className="mx-5 mb-5 rounded-xl border border-[var(--band-brd)] bg-[var(--band)] p-4 text-[12.5px] text-[var(--band-text)]">
<div className="flex items-center justify-between gap-2">
<b>{ta("linkedRisks")}</b>
{canWrite && (
<Button variant="outline" size="sm" nativeButton={false} render={<Link href={`/risks?new=1&asset=${service.id}`} />}>
<Plus className="size-3.5" /> {tRisks("createFromAsset")}
</Button>
)}
</div>
{service.riskAssets.length === 0 && <p className="mt-1">{tc("none")}</p>}
<ul className="mt-2 space-y-1.5">
{service.riskAssets.map((ra) => (
<li key={ra.id} className="flex items-center gap-2">
<Link href={`/risks?detail=${ra.risk.id}`} className="font-bold hover:underline">{riskRef(ra.risk.refNo)}</Link>
<Link href={`/risks?detail=${ra.risk.id}`} className="hover:underline">{ra.risk.title}</Link>
<Pill tone={RISK_PILL_TONE[riskLevel(ra.risk.score)]}>{ra.risk.score} · {tLevel(riskLevel(ra.risk.score))}</Pill>
</li>
))}
</ul>
</div>
{/* Verantwortungsmatrix (RACI) — darunter, über die volle Breite */}
<div className="mx-5 mb-5">
<section className="rounded-xl border">
<div className="flex items-center justify-between gap-2 p-4 pb-2">
<div>
<p className="font-heading text-[15px] font-semibold">{t("raci")}</p>
<p className="text-[12px] text-muted-foreground">{t("raciNote")}</p>
</div>
{canWrite && (
<details className="relative">
<summary className="bg-grad-soft inline-flex h-8 cursor-pointer list-none items-center gap-1.5 rounded-lg px-2.5 font-heading text-sm font-semibold text-white select-none hover:opacity-90 [&::-webkit-details-marker]:hidden">
<Plus className="size-4" /> {t("addControl")}
</summary>
<form action={addRaci.bind(null, service.id)} className="shadow-card absolute right-0 z-10 mt-2 w-80 space-y-2 rounded-xl border bg-card p-3 text-sm">
<Input name="controlRef" required list="isa-controls" placeholder={t("control")} />
<datalist id="isa-controls">
{ISA_CONTROLS.map((c) => (
<option key={c.ref} value={c.ref}>{c.title}</option>
))}
</datalist>
<Input name="title" placeholder={t("controlTitle")} />
<select name="responsibility" className={inputCls}>
{(["PROVIDER", "US", "SHARED"] as const).map((r) => (
<option key={r} value={r}>{tParty(r)}</option>
))}
</select>
<Input name="evidenceRef" placeholder={t("evidence")} />
<Button type="submit" variant="secondary" size="sm">{tc("add")}</Button>
</form>
</details>
)}
</div>
{service.raci.length === 0 ? (
<p className="p-4 pt-0 text-sm text-muted-foreground">{t("raciEmpty")}</p>
) : (
<div className="overflow-hidden">
<div className="grid grid-cols-[5rem_1fr_8rem_1fr_auto] items-center gap-x-3 border-y bg-muted/40 px-4 py-2 text-[10.5px] font-bold tracking-[.04em] text-muted-foreground uppercase">
<span>{t("control")}</span>
<span>{t("controlTitle")}</span>
<span>{t("responsibility")}</span>
<span>{t("evidence")}</span>
<span />
</div>
{service.raci.map((r) => {
const ctrl = ISA_CONTROLS.find((c) => c.ref === r.controlRef);
return (
<div key={r.id} className="grid grid-cols-[5rem_1fr_8rem_1fr_auto] items-center gap-x-3 border-b px-4 py-2.5 text-sm last:border-0">
<b className={r.applicable ? "" : "opacity-40"}>{r.controlRef}</b>
<span className={r.applicable ? "" : "opacity-40"}>{r.title ?? ctrl?.title ?? ""}</span>
<span>
{canWrite ? (
<form action={cycleRaci.bind(null, service.id, r.id)}>
<button type="submit" title="Verantwortung wechseln">
<Pill tone={RACI_TONE[r.responsibility]}>{tParty(r.responsibility)}</Pill>
</button>
</form>
) : (
<Pill tone={RACI_TONE[r.responsibility]}>{tParty(r.responsibility)}</Pill>
)}
</span>
<span className="truncate text-muted-foreground">{r.evidenceRef ?? tc("none")}</span>
{canWrite && (
<form action={deleteRaci.bind(null, service.id, r.id)}>
<button type="submit" title={tc("remove")} className="text-muted-foreground hover:text-destructive"><Trash2 className="size-3.5" /></button>
</form>
)}
</div>
);
})}
</div>
)}
</section>
</div>
</Modal>
);
}
/* ─────────────────────── Formular ─────────────────────── */
async function ServiceFields({ service, providers, formId }: { service?: ServiceAssetDetail; providers: { id: string; name: string }[]; formId?: string }) {
const t = await getTranslations("services");
const tA = await getTranslations("assets");
const tLevel = await getTranslations("protectionLevel");
const f = formId ? { form: formId } : {};
const p = service?.serviceProfile;
return (
<div className="grid gap-4 md:grid-cols-2">
<div className="md:col-span-2">
<Label htmlFor="name">{t("name")}</Label>
<Input id="name" name="name" required defaultValue={service?.name} className="mt-1" {...f} />
</div>
<div>
<Label htmlFor="providerAssetId">{t("provider")}</Label>
<select id="providerAssetId" name="providerAssetId" defaultValue={p?.providerAssetId ?? ""} className={`${inputCls} mt-1`} {...f}>
<option value="">{t("noProvider")}</option>
{providers.map((pr) => (
<option key={pr.id} value={pr.id}>{pr.name}</option>
))}
</select>
</div>
<div>
<Label>{t("criticality")}</Label>
<div className="mt-1"><SegmentedRating name="criticality" defaultValue={p?.criticality ?? 1} form={formId} /></div>
</div>
<fieldset className="md:col-span-2">
<legend className="text-sm font-medium">{t("protection")} (1–4)</legend>
<div className="mt-2 grid grid-cols-3 gap-4">
{(
[
["confidentiality", tA("confidentiality")],
["integrity", tA("integrity")],
["availability", tA("availability")],
] as const
).map(([n, label]) => (
<div key={n}>
<Label>{label}</Label>
<div className="mt-1">
<SegmentedRating name={n} defaultValue={(service?.[n] as number) ?? 1} low={tLevel("1")} high={tLevel("4")} form={formId} />
</div>
</div>
))}
</div>
</fieldset>
<label className="flex items-center gap-2 text-sm md:col-span-2">
<input type="checkbox" name="internal" defaultChecked={p?.internal} {...f} /> {t("internal")}
</label>
<div className="md:col-span-2">
<Label htmlFor="notes">{t("notes")}</Label>
<Textarea id="notes" name="notes" rows={2} defaultValue={p?.notes ?? ""} className="mt-1" {...f} />
</div>
</div>
);
}
export async function ServiceCreateModal({ providers }: { providers: { id: string; name: string }[] }) {
const t = await getTranslations("services");
const tc = await getTranslations("common");
return (
<Modal title={t("createTitle")} closeHref="/suppliers?tab=services" closeLabel={t("close")}>
<form action={createService} className="space-y-4 p-5">
<ServiceFields providers={providers} />
<div className="flex gap-2 pt-1">
<Button type="submit">{tc("save")}</Button>
<Button variant="outline" nativeButton={false} render={<Link href="/suppliers?tab=services" />}>{tc("cancel")}</Button>
</div>
</form>
</Modal>
);
}
export async function ServiceEditModal({ service, providers, backHref = "/suppliers?tab=services" }: { service: ServiceAssetDetail; providers: { id: string; name: string }[]; backHref?: string }) {
const t = await getTranslations("services");
const tc = await getTranslations("common");
const FORM = "service-edit";
const detailHref = withQuery(backHref, "detail", service.id);
return (
<Modal
title={t("editTitle")}
sub={service.name}
headerExtra={
<details className="relative">
<summary className="grid size-8 cursor-pointer list-none place-items-center rounded-md text-muted-foreground hover:bg-muted [&::-webkit-details-marker]:hidden"><Trash2 className="size-4" /></summary>
<div className="shadow-card absolute right-0 z-20 mt-1 w-48 rounded-xl border bg-card p-1.5">
<form action={deleteService.bind(null, service.id, backHref)}>
<button type="submit" className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm text-destructive hover:bg-destructive/10"><Trash2 className="size-4" /> {tc("delete")}</button>
</form>
</div>
</details>
}
closeHref={detailHref}
closeLabel={t("close")}
footer={
<>
<Button variant="outline" nativeButton={false} render={<Link href={detailHref} />}>{tc("cancel")}</Button>
<Button type="submit" form={FORM}>{tc("save")}</Button>
</>
}
>
<form id={FORM} action={updateService.bind(null, service.id)} className="hidden">
<input type="hidden" name="returnTo" value={backHref} />
</form>
<div className="p-5">
<ServiceFields service={service} providers={providers} formId={FORM} />
</div>
</Modal>
);
}
-280
View File
@@ -1,280 +0,0 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Network, Pencil, Plus, Trash2 } from "lucide-react";
import type { Prisma } from "@prisma/client";
import { createSoftware, deleteSoftware, updateSoftware } from "@/server/actions/software";
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 { SegmentedRating } from "@/components/segmented-rating";
import { CiaBadge, CiaLegend, CriticalityPill, Pill, Tag } from "@/components/mockup-ui";
import { softwareRef, withQuery } from "@/lib/supplier";
import { riskLevel, riskRef, RISK_PILL_TONE } from "@/lib/risk";
export type SoftwareAssetDetail = Prisma.AssetGetPayload<{
include: {
softwareProfile: { include: { provider: { select: { id: true; name: true } } } };
riskAssets: { include: { risk: { select: { id: true; refNo: true; title: true; score: 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 } } } };
};
}>;
const inputCls = "h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm";
const APPROVAL_TONE = { BEANTRAGT: "warn", FREIGEGEBEN: "ok", GESPERRT: "risk" } as const;
const APPROVAL_VALUES = ["BEANTRAGT", "FREIGEGEBEN", "GESPERRT"] as const;
/* ─────────────────────── Detail-Cockpit ─────────────────────── */
export async function SoftwareDetailModal({ software, canWrite, backHref = "/suppliers?tab=software" }: { software: SoftwareAssetDetail; canWrite: boolean; backHref?: string }) {
const t = await getTranslations("software");
const ta = await getTranslations("assets");
const tCrit = await getTranslations("criticality");
const tType = await getTranslations("assetType");
const tStatus = await getTranslations("softwareStatus");
const tLevel = await getTranslations("riskLevel");
const tRisks = await getTranslations("risks");
const tDep = await getTranslations("dependencies");
const tc = await getTranslations("common");
const p = software.softwareProfile!;
return (
<Modal
title={software.name}
sub={t("detailSub")}
headerExtra={
<span className="flex items-center gap-2">
<Tag>{tType(software.type)}</Tag>
<Pill tone={APPROVAL_TONE[p.approvalStatus]}>{tStatus(p.approvalStatus)}</Pill>
<CriticalityPill level={p.criticality} label={tCrit(String(p.criticality))} />
</span>
}
closeHref={backHref}
closeLabel={t("close")}
footer={
<>
{canWrite && (
<Button variant="outline" nativeButton={false} render={<Link href={withQuery(backHref, "edit", software.id)} />}>
<Pencil className="size-4" /> {tc("edit")}
</Button>
)}
<Button nativeButton={false} render={<Link href={backHref} />}>{t("close")}</Button>
</>
}
>
<div className="grid gap-5 p-5 md:grid-cols-2">
{/* Stammdaten */}
<div>
<div className="mb-2.5 flex items-center gap-2.5">
<Pill tone="violet">{t("masterPill")}</Pill>
</div>
<div className="rounded-xl border border-l-[3px] border-l-[var(--primary)] bg-[var(--surface-soft)] p-4">
<div className="flex items-center justify-between gap-2">
<b>{softwareRef(p.refNo)} · {software.name}</b>
<CiaBadge c={software.confidentiality} i={software.integrity} a={software.availability} labels />
</div>
<div className="mt-1.5 space-y-1 text-[12.5px] leading-relaxed text-muted-foreground">
<div>
{t("provider")}:{" "}
{p.provider ? (
<Link href={`/suppliers?detail=${p.provider.id}`} className="hover:underline">{p.provider.name}</Link>
) : t("noProvider")}
</div>
{p.version && <div>{t("version")}: {p.version}</div>}
<div>{t("approvalStatus")}: {tStatus(p.approvalStatus)}{p.approvedBy ? ` · ${t("approvedBy")}: ${p.approvedBy}` : ""}</div>
<div>{t("criticality")}: {tCrit(String(p.criticality))}</div>
{p.nextReview && <div>{t("nextReview")}: {p.nextReview.toISOString().slice(0, 10)}</div>}
</div>
{p.notes && <p className="mt-2 text-[12.5px] leading-relaxed">{p.notes}</p>}
</div>
</div>
{/* Verknüpfte Assets */}
<div>
<div className="mb-2.5 flex items-center gap-2.5">
<Pill tone="info">{ta("depPill")}</Pill>
</div>
{software.relationsFrom.length === 0 && software.relationsTo.length === 0 && (
<p className="text-sm text-muted-foreground">{tc("none")}</p>
)}
{software.relationsFrom.length > 0 && (
<table className="w-full text-sm">
<thead>
<tr><th /><th /><th className="pb-1 text-right font-normal"><CiaLegend /></th></tr>
</thead>
<tbody>
{software.relationsFrom.map((rel) => (
<tr key={rel.id} className="border-b last:border-0">
<td className="py-2.5 pr-2 font-bold">
<Link href={`/assets?detail=${rel.relatedAsset.id}`} className="hover:underline">{rel.relatedAsset.name}</Link>
</td>
<td className="py-2.5 pr-2"><Tag>{tType(rel.relatedAsset.type)}</Tag></td>
<td className="py-2.5 text-right">
<CiaBadge c={rel.relatedAsset.confidentiality} i={rel.relatedAsset.integrity} a={rel.relatedAsset.availability} />
</td>
</tr>
))}
</tbody>
</table>
)}
<Link href="/dependencies" className="mt-3 inline-flex items-center gap-1.5 text-[12.5px] font-semibold text-[var(--info)] hover:underline">
<Network className="size-3.5" /> {tDep("openGraph")} →
</Link>
</div>
</div>
{/* Zugeordnete Risiken */}
<div className="mx-5 mb-5 rounded-xl border border-[var(--band-brd)] bg-[var(--band)] p-4 text-[12.5px] text-[var(--band-text)]">
<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=${software.id}`} />}>
<Plus className="size-3.5" /> {tRisks("createFromAsset")}
</Button>
)}
</div>
{software.riskAssets.length === 0 && <p className="mt-1">{tc("none")}</p>}
<ul className="mt-2 space-y-1.5">
{software.riskAssets.map((ra) => (
<li key={ra.id} className="flex items-center gap-2">
<Link href={`/risks?detail=${ra.risk.id}`} className="font-bold hover:underline">{riskRef(ra.risk.refNo)}</Link>
<Link href={`/risks?detail=${ra.risk.id}`} className="hover:underline">{ra.risk.title}</Link>
<Pill tone={RISK_PILL_TONE[riskLevel(ra.risk.score)]}>{ra.risk.score} · {tLevel(riskLevel(ra.risk.score))}</Pill>
</li>
))}
</ul>
</div>
</Modal>
);
}
/* ─────────────────────── Formular ─────────────────────── */
async function SoftwareFields({ software, providers, formId }: { software?: SoftwareAssetDetail; providers: { id: string; name: string }[]; formId?: string }) {
const t = await getTranslations("software");
const tA = await getTranslations("assets");
const tLevel = await getTranslations("protectionLevel");
const tStatus = await getTranslations("softwareStatus");
const f = formId ? { form: formId } : {};
const p = software?.softwareProfile;
return (
<div className="grid gap-4 md:grid-cols-2">
<div className="md:col-span-2">
<Label htmlFor="name">{t("name")}</Label>
<Input id="name" name="name" required defaultValue={software?.name} className="mt-1" {...f} />
</div>
<div>
<Label htmlFor="providerAssetId">{t("provider")}</Label>
<select id="providerAssetId" name="providerAssetId" defaultValue={p?.providerAssetId ?? ""} className={`${inputCls} mt-1`} {...f}>
<option value="">{t("noProvider")}</option>
{providers.map((pr) => (
<option key={pr.id} value={pr.id}>{pr.name}</option>
))}
</select>
</div>
<div>
<Label htmlFor="version">{t("version")}</Label>
<Input id="version" name="version" defaultValue={p?.version ?? ""} className="mt-1" {...f} />
</div>
<div>
<Label htmlFor="approvalStatus">{t("approvalStatus")}</Label>
<select id="approvalStatus" name="approvalStatus" defaultValue={p?.approvalStatus ?? "BEANTRAGT"} className={`${inputCls} mt-1`} {...f}>
{APPROVAL_VALUES.map((s) => (
<option key={s} value={s}>{tStatus(s)}</option>
))}
</select>
</div>
<div>
<Label htmlFor="approvedBy">{t("approvedBy")}</Label>
<Input id="approvedBy" name="approvedBy" defaultValue={p?.approvedBy ?? ""} className="mt-1" {...f} />
</div>
<div>
<Label>{t("criticality")}</Label>
<div className="mt-1"><SegmentedRating name="criticality" defaultValue={p?.criticality ?? 1} form={formId} /></div>
</div>
<div>
<Label htmlFor="nextReview">{t("nextReview")}</Label>
<Input id="nextReview" name="nextReview" type="date" defaultValue={p?.nextReview ? p.nextReview.toISOString().slice(0, 10) : ""} className="mt-1" {...f} />
</div>
<fieldset className="md:col-span-2">
<legend className="text-sm font-medium">{t("protection")} (1–4)</legend>
<div className="mt-2 grid grid-cols-3 gap-4">
{(
[
["confidentiality", tA("confidentiality")],
["integrity", tA("integrity")],
["availability", tA("availability")],
] as const
).map(([n, label]) => (
<div key={n}>
<Label>{label}</Label>
<div className="mt-1">
<SegmentedRating name={n} defaultValue={(software?.[n] as number) ?? 1} low={tLevel("1")} high={tLevel("4")} form={formId} />
</div>
</div>
))}
</div>
</fieldset>
<div className="md:col-span-2">
<Label htmlFor="notes">{t("notes")}</Label>
<Textarea id="notes" name="notes" rows={2} defaultValue={p?.notes ?? ""} className="mt-1" {...f} />
</div>
</div>
);
}
export async function SoftwareCreateModal({ providers }: { providers: { id: string; name: string }[] }) {
const t = await getTranslations("software");
const tc = await getTranslations("common");
return (
<Modal title={t("createTitle")} closeHref="/suppliers?tab=software" closeLabel={t("close")}>
<form action={createSoftware} className="space-y-4 p-5">
<SoftwareFields providers={providers} />
<div className="flex gap-2 pt-1">
<Button type="submit">{tc("save")}</Button>
<Button variant="outline" nativeButton={false} render={<Link href="/suppliers?tab=software" />}>{tc("cancel")}</Button>
</div>
</form>
</Modal>
);
}
export async function SoftwareEditModal({ software, providers, backHref = "/suppliers?tab=software" }: { software: SoftwareAssetDetail; providers: { id: string; name: string }[]; backHref?: string }) {
const t = await getTranslations("software");
const tc = await getTranslations("common");
const FORM = "software-edit";
const detailHref = withQuery(backHref, "detail", software.id);
return (
<Modal
title={t("editTitle")}
sub={software.name}
headerExtra={
<details className="relative">
<summary className="grid size-8 cursor-pointer list-none place-items-center rounded-md text-muted-foreground hover:bg-muted [&::-webkit-details-marker]:hidden"><Trash2 className="size-4" /></summary>
<div className="shadow-card absolute right-0 z-20 mt-1 w-48 rounded-xl border bg-card p-1.5">
<form action={deleteSoftware.bind(null, software.id, backHref)}>
<button type="submit" className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm text-destructive hover:bg-destructive/10"><Trash2 className="size-4" /> {tc("delete")}</button>
</form>
</div>
</details>
}
closeHref={detailHref}
closeLabel={t("close")}
footer={
<>
<Button variant="outline" nativeButton={false} render={<Link href={detailHref} />}>{tc("cancel")}</Button>
<Button type="submit" form={FORM}>{tc("save")}</Button>
</>
}
>
<form id={FORM} action={updateSoftware.bind(null, software.id)} className="hidden">
<input type="hidden" name="returnTo" value={backHref} />
</form>
<div className="p-5">
<SoftwareFields software={software} providers={providers} formId={FORM} />
</div>
</Modal>
);
}
-217
View File
@@ -1,217 +0,0 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Check, X, AlertTriangle, ShieldCheck } from "lucide-react";
import { cn } from "@/lib/utils";
import {
PROTECTION_LABEL,
type ProtectionLevel,
type ReqContext,
type ReqTheme,
type Tier,
} from "@/lib/supplier";
type SerReq = { key: string; label: string; control: string; tier: Tier; theme: ReqTheme; met: boolean };
const TIER_MIN: Record<Tier, number> = { must: 1, should: 1, high: 2, veryhigh: 3 };
const THEME_LABEL: Record<ReqTheme, string> = {
contract: "Vertrag & Geheimhaltung",
assessment: "Bewertung & Nachweise",
transparency: "Transparenz (sehr hoher Schutzbedarf)",
};
export function SupplierRequirements({
reqs,
ctx,
actualLevel,
maxCia,
linkedAssets,
computedMaturity,
isbValue,
targetMaturity,
gateEvidenceHref,
gateDecisionHref,
createRiskAction,
}: {
reqs: SerReq[];
ctx: ReqContext;
actualLevel: ProtectionLevel;
maxCia: number;
linkedAssets: { name: string; c: number; i: number; a: number }[];
computedMaturity: number;
isbValue: number | null;
targetMaturity: number;
gateEvidenceHref: string;
gateDecisionHref: string;
createRiskAction: () => Promise<void>;
}) {
const [sim, setSim] = useState<ProtectionLevel>(actualLevel);
const status = (r: SerReq): "met" | "open" | "na" =>
sim < TIER_MIN[r.tier] ? "na" : r.met ? "met" : "open";
const gateActive = sim === 3 && !ctx.hasAudit;
const gateSatisfied = ctx.hasAudit || (ctx.hasManagementDecision && ctx.hasLinkedRisk);
const themes: ReqTheme[] = ["contract", "assessment", "transparency"];
const maturity = isbValue ?? computedMaturity;
const maturityOk = maturity >= targetMaturity;
return (
<div className="space-y-4">
{/* Schutzbedarf-Banner mit Simulation */}
<div className="rounded-xl border border-[var(--panel-brd)] bg-[var(--surface-soft)] p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="font-heading text-[14px] font-semibold">Schutzbedarf steuert die Anforderungen</p>
<p className="text-[12px] text-muted-foreground">
Verknüpfte Assets:{" "}
{linkedAssets.length
? linkedAssets.map((a) => `${a.name} (${a.c}/${a.i}/${a.a})`).join(" · ")
: "—"}{" "}
· abgeleitet: <b className="text-foreground">{PROTECTION_LABEL[actualLevel]}</b> (max C/I/A {maxCia})
</p>
</div>
<div className="flex items-center gap-2 text-[12px]">
<span className="text-muted-foreground">Simulieren:</span>
{([1, 2, 3] as ProtectionLevel[]).map((lvl) => (
<button
key={lvl}
onClick={() => setSim(lvl)}
className={cn(
"rounded-lg border px-2.5 py-1 font-heading font-semibold",
sim === lvl
? lvl === 3
? "border-transparent bg-[var(--risk)] text-white"
: "border-transparent bg-[var(--primary)] text-white"
: "border-[var(--panel-brd)] bg-[var(--elevated)] text-muted-foreground"
)}
>
{PROTECTION_LABEL[lvl]}
</button>
))}
</div>
</div>
</div>
{/* Flache Anforderungsliste, nach Themen gruppiert */}
<div className="rounded-xl border p-4">
<p className="font-heading text-[15px] font-semibold">Anforderungen</p>
<p className="mb-2 text-[12px] text-muted-foreground">
Nur die tatsächlich relevanten Anforderungen — mit Referenz und Herkunfts-Control.
</p>
{themes.map((theme) => {
const rows = reqs.filter((r) => r.theme === theme);
if (rows.length === 0) return null;
return (
<div key={theme} className="mt-3">
<p className="text-[10.5px] font-bold tracking-[.05em] text-muted-foreground uppercase">
{THEME_LABEL[theme]}
</p>
<ul className="mt-1">
{rows.map((r) => {
const st = status(r);
return (
<li
key={r.key}
className={cn(
"flex items-center gap-2.5 border-b border-[var(--panel-brd)] py-2 text-[13px] last:border-0",
st === "na" && "opacity-40"
)}
>
{st === "met" ? (
<Check className="size-4 shrink-0 text-[var(--ok)]" />
) : st === "open" ? (
<X className="size-4 shrink-0 text-[var(--risk)]" />
) : (
<span className="size-4 shrink-0 rounded-full border border-dashed border-muted-foreground/40" />
)}
<span className="min-w-0 flex-1">{r.label}</span>
{st === "open" && (
<Link
href={r.theme === "assessment" ? gateEvidenceHref : gateDecisionHref}
className="text-[11.5px] font-semibold text-[var(--info)] hover:underline"
>
+ verknüpfen
</Link>
)}
<span className="text-[10.5px] font-bold text-muted-foreground/70">{r.control}</span>
</li>
);
})}
</ul>
</div>
);
})}
{/* Gate-Panel */}
{gateActive && (
<div
className={cn(
"mt-4 rounded-xl border p-4",
gateSatisfied
? "border-[rgba(57,192,127,0.4)] bg-[rgba(57,192,127,0.1)]"
: "border-[rgba(255,107,107,0.4)] bg-[rgba(255,107,107,0.1)]"
)}
>
<p className="flex items-center gap-2 text-[13px] font-semibold text-[var(--risk)]">
<AlertTriangle className="size-4" /> Sehr hoher Schutzbedarf — Kompensation erforderlich
</p>
<p className="mt-1 text-[12px] text-muted-foreground">
Kein gültiges Third-Party-Audit / TISAX-Label. Nachweis über eine der Optionen:
</p>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<div className="rounded-lg border border-[var(--panel-brd)] p-3">
<p className="text-[12px] font-semibold">Option A · Audit-Nachweis</p>
<Link
href={gateEvidenceHref}
className="mt-2 inline-block rounded-lg bg-[var(--primary)] px-2.5 py-1.5 text-[12px] font-semibold text-white hover:opacity-90"
>
TISAX-Label / Audit erfassen
</Link>
<p className="mt-2 text-[11px] text-muted-foreground">oder selbst durchgeführtes Lieferanten-Audit</p>
</div>
<div className="rounded-lg border border-[var(--panel-brd)] p-3">
<p className="text-[12px] font-semibold">Option B · Kompensation (beides nötig)</p>
<div className="mt-2 flex flex-col gap-2">
<div className="flex items-center justify-between gap-2 text-[12px]">
<span className={ctx.hasManagementDecision ? "text-[var(--ok)]" : ""}>Managemententscheidung</span>
<Link href={gateDecisionHref} className="rounded-md border border-[var(--panel-brd)] px-2 py-1 text-[11.5px] font-semibold hover:bg-[var(--accent)]">
Dokumentieren
</Link>
</div>
<div className="flex items-center justify-between gap-2 text-[12px]">
<span className={ctx.hasLinkedRisk ? "text-[var(--ok)]" : ""}>Risiko im Risikomodul</span>
<form action={createRiskAction}>
<button type="submit" className="rounded-md border border-[var(--panel-brd)] px-2 py-1 text-[11.5px] font-semibold hover:bg-[var(--accent)]">
Risiko anlegen
</button>
</form>
</div>
</div>
</div>
</div>
</div>
)}
</div>
{/* Reifegrad-/Konformitätskarte */}
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border p-4">
<div className="flex items-center gap-3">
<ShieldCheck className={cn("size-6", maturityOk ? "text-[var(--ok)]" : "text-[var(--warn)]")} />
<div>
<p className="text-[12px] text-muted-foreground">Reifegrad</p>
<p className="font-heading text-2xl font-bold leading-none">
{maturity.toFixed(1)} <span className="text-[13px] font-normal text-muted-foreground">/ {targetMaturity.toFixed(1)}</span>
</p>
</div>
</div>
<p className="text-[11.5px] text-muted-foreground">
{isbValue != null ? "ISB-freigegeben" : `berechnet: ${computedMaturity.toFixed(1)}`}
</p>
</div>
</div>
);
}
-447
View File
@@ -1,447 +0,0 @@
import Link from "next/link";
import { getFormatter, getTranslations } from "next-intl/server";
import { Pencil, Plus, Trash2, X } from "lucide-react";
import type { Prisma } from "@prisma/client";
import {
addAssessment,
addContract,
addDecision,
addEvidence,
addNda,
approveMaturity,
createGateRisk,
createSupplier,
deleteChild,
deleteSupplier,
updateSupplier,
} from "@/server/actions/suppliers";
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 { SegmentedRating } from "@/components/segmented-rating";
import { Pill } from "@/components/mockup-ui";
import { SupplierRequirements } from "@/components/supplier-cockpit";
import {
supplierRef,
withQuery,
SUPPLIER_LIFECYCLE_TONE,
protectionLevel,
PROTECTION_LABEL,
buildReqContext,
REQUIREMENTS,
conformity,
computedMaturity,
CONFORMITY_TONE,
CONFORMITY_LABEL,
TARGET_MATURITY,
isExpired,
isExpiring,
} from "@/lib/supplier";
export type SupplierAssetDetail = Prisma.AssetGetPayload<{
include: {
supplierProfile: true;
contracts: true;
ndas: true;
evidence: true;
assessments: true;
subcontractors: true;
decisions: true;
maturity: true;
riskAssets: { include: { risk: { select: { id: true; refNo: true; title: true; score: true } } } };
relationsFrom: { include: { relatedAsset: { select: { name: true; confidentiality: true; integrity: true; availability: true } } } };
relationsTo: { include: { asset: { select: { name: true; confidentiality: true; integrity: true; availability: true } } } };
};
}>;
const inputCls = "h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm";
function contextOf(s: SupplierAssetDetail) {
const linkedAssets = [
{ name: s.name, c: s.confidentiality, i: s.integrity, a: s.availability },
...s.relationsFrom.map((r) => ({ name: r.relatedAsset.name, c: r.relatedAsset.confidentiality, i: r.relatedAsset.integrity, a: r.relatedAsset.availability })),
...s.relationsTo.map((r) => ({ name: r.asset.name, c: r.asset.confidentiality, i: r.asset.integrity, a: r.asset.availability })),
];
const maxCia = Math.max(...linkedAssets.map((a) => Math.max(a.c, a.i, a.a)));
const level = protectionLevel(maxCia);
const ctx = buildReqContext({
contracts: s.contracts,
ndas: s.ndas,
evidence: s.evidence,
assessments: s.assessments,
subcontractors: s.subcontractors,
nextReview: s.supplierProfile?.nextReview ?? null,
linkedRiskCount: s.riskAssets.length,
decisionCount: s.decisions.length,
});
return { linkedAssets: linkedAssets.slice(1), maxCia, level, ctx };
}
/* ─────────────────────── Cockpit (Detail) ─────────────────────── */
export async function SupplierDetailModal({ supplier, canWrite, backHref = "/suppliers" }: { supplier: SupplierAssetDetail; canWrite: boolean; backHref?: string }) {
const t = await getTranslations("suppliers");
const tLife = await getTranslations("supplierStatus");
const tEKind = await getTranslations("evidenceKind");
const tc = await getTranslations("common");
const fmt = await getFormatter();
const date = (d: Date | null) => (d ? fmt.dateTime(d, { dateStyle: "medium" }) : tc("none"));
const p = supplier.supplierProfile!;
const { linkedAssets, maxCia, level, ctx } = contextOf(supplier);
const conf = conformity(level, ctx);
const computed = computedMaturity(level, ctx);
const reqs = REQUIREMENTS.map((r) => ({ key: r.key, label: r.label, control: r.control, tier: r.tier, theme: r.theme, met: r.met(ctx) }));
const editHref = withQuery(backHref, "edit", supplier.id);
const gateEvidenceHref = editHref;
const gateDecisionHref = editHref;
return (
<Modal
title={`${supplierRef(p.refNo)} · ${supplier.name}`}
sub={p.serviceDesc ?? t("detailSub")}
headerExtra={
<span className="flex items-center gap-2">
{p.nis2Relevant && <Pill tone="info">NIS2</Pill>}
<Pill tone={level === 3 ? "risk" : level === 2 ? "warn" : "ok"}>Schutzbedarf {PROTECTION_LABEL[level]}</Pill>
<Pill tone={CONFORMITY_TONE[conf]}>{CONFORMITY_LABEL[conf]}</Pill>
</span>
}
closeHref={backHref}
closeLabel={t("close")}
footer={
<>
{canWrite && (
<Button variant="outline" nativeButton={false} render={<Link href={editHref} />}>
<Pencil className="size-4" /> {tc("edit")}
</Button>
)}
<Button nativeButton={false} render={<Link href={backHref} />}>{t("close")}</Button>
</>
}
>
<div className="space-y-5 p-5">
<SupplierRequirements
reqs={reqs}
ctx={ctx}
actualLevel={level}
maxCia={maxCia}
linkedAssets={linkedAssets}
computedMaturity={computed}
isbValue={supplier.maturity?.isbValue ?? null}
targetMaturity={TARGET_MATURITY}
gateEvidenceHref={gateEvidenceHref}
gateDecisionHref={gateDecisionHref}
createRiskAction={createGateRisk.bind(null, supplier.id)}
/>
{/* Risiken */}
<section>
<p className="text-sm font-semibold">{t("risks")}</p>
{supplier.riskAssets.length === 0 && <p className="mt-1 text-sm text-muted-foreground">{tc("none")}</p>}
<ul className="mt-1.5 space-y-1.5 text-sm">
{supplier.riskAssets.map((ra) => (
<li key={ra.id} className="flex items-center gap-2">
<Link href={`/risks?detail=${ra.risk.id}`} className="font-bold hover:underline">R-{String(ra.risk.refNo).padStart(3, "0")}</Link>
<Link href={`/risks?detail=${ra.risk.id}`} className="hover:underline">{ra.risk.title}</Link>
</li>
))}
</ul>
</section>
{/* Verträge & NDA & Nachweise (read) */}
<div className="grid gap-4 md:grid-cols-2">
<section>
<p className="text-sm font-semibold">{t("contracts")} & {t("ndas")}</p>
<ul className="mt-1.5 space-y-1.5 text-sm">
{supplier.contracts.map((c) => (
<li key={c.id} className="flex flex-wrap items-center gap-2">
<span className="font-medium">{c.reference ?? c.type}</span>
{c.avDpa && <Pill tone="ok">AV/DPA</Pill>}
{c.validTo && <span className={isExpiring(c.validTo) ? "text-[var(--warn)]" : "text-muted-foreground"}>{t("validTo")}: {date(c.validTo)}</span>}
</li>
))}
{supplier.ndas.map((n) => (
<li key={n.id} className="flex flex-wrap items-center gap-2">
<span className="font-medium">NDA: {n.subject ?? n.parties ?? ""}</span>
{n.validTo && <span className={isExpiring(n.validTo) ? "text-[var(--warn)]" : "text-muted-foreground"}>{t("validTo")}: {date(n.validTo)}</span>}
</li>
))}
{supplier.contracts.length + supplier.ndas.length === 0 && <li className="text-muted-foreground">{tc("none")}</li>}
</ul>
</section>
<section>
<p className="text-sm font-semibold">{t("evidence")}</p>
<ul className="mt-1.5 space-y-1.5 text-sm">
{supplier.evidence.map((e) => (
<li key={e.id} className="flex flex-wrap items-center gap-2">
<Pill tone="violet">{tEKind(e.kind)}</Pill>
<span>{e.name ?? tEKind(e.kind)}</span>
{e.adequacyChecked && <Pill tone="ok">{t("adequacy")}</Pill>}
{e.validTo && <span className={isExpired(e.validTo) ? "text-[var(--risk)]" : isExpiring(e.validTo) ? "text-[var(--warn)]" : "text-muted-foreground"}>{t("expires")} {date(e.validTo)}</span>}
</li>
))}
{supplier.evidence.length === 0 && <li className="text-muted-foreground">{tc("none")}</li>}
</ul>
</section>
</div>
{/* ISB-Freigabe */}
{canWrite && (
<section className="rounded-xl border border-[var(--band-brd)] bg-[var(--band)] p-4">
<p className="text-sm font-semibold">{t("isbApproval")}</p>
<form action={approveMaturity.bind(null, supplier.id, computed)} className="mt-2 flex flex-wrap items-end gap-3 text-sm">
<div>
<Label htmlFor="isbValue">{t("isbValue")} (0–3)</Label>
<Input id="isbValue" name="isbValue" type="number" min={0} max={3} step={0.1} defaultValue={supplier.maturity?.isbValue ?? computed} className="mt-1 w-28" />
</div>
<div className="flex-1">
<Label htmlFor="justification">{t("justification")}</Label>
<Input id="justification" name="justification" defaultValue={supplier.maturity?.isbJustification ?? ""} className="mt-1" />
</div>
<Button type="submit">{t("approve")}</Button>
</form>
<p className="mt-1.5 text-[11px] text-muted-foreground">{t("approvalNote")}</p>
</section>
)}
{/* Lifecycle + Datenkategorien */}
<div className="text-[12px] text-muted-foreground">
{t("status")}: <Pill tone={SUPPLIER_LIFECYCLE_TONE[p.lifecycle]}>{tLife(p.lifecycle)}</Pill>
{p.dataCategories.length > 0 && ` · ${t("dataCategories")}: ${p.dataCategories.join(", ")}`}
</div>
</div>
</Modal>
);
}
/* ─────────────────────── Stammdaten-Formular ─────────────────────── */
async function SupplierFields({ supplier, formId }: { supplier?: SupplierAssetDetail; formId?: string }) {
const t = await getTranslations("suppliers");
const tLife = await getTranslations("supplierStatus");
const tA = await getTranslations("assets");
const tLevel = await getTranslations("protectionLevel");
const f = formId ? { form: formId } : {};
const p = supplier?.supplierProfile;
return (
<div className="grid gap-4 md:grid-cols-2">
<div className="md:col-span-2">
<Label htmlFor="name">{t("name")}</Label>
<Input id="name" name="name" required defaultValue={supplier?.name} className="mt-1" {...f} />
</div>
<div>
<Label htmlFor="sector">{t("sector")}</Label>
<Input id="sector" name="sector" defaultValue={p?.sector ?? ""} className="mt-1" {...f} />
</div>
<div>
<Label htmlFor="contact">{t("contact")}</Label>
<Input id="contact" name="contact" defaultValue={p?.contact ?? ""} className="mt-1" {...f} />
</div>
<div className="md:col-span-2">
<Label htmlFor="serviceDesc">{t("services")}</Label>
<Textarea id="serviceDesc" name="serviceDesc" rows={2} defaultValue={p?.serviceDesc ?? ""} className="mt-1" {...f} />
</div>
<div>
<Label htmlFor="lifecycle">{t("status")}</Label>
<select id="lifecycle" name="lifecycle" defaultValue={p?.lifecycle ?? "ACTIVE"} className={`${inputCls} mt-1`} {...f}>
{(["ACTIVE", "ONBOARDING", "UNDER_REVIEW", "OFFBOARDED"] as const).map((v) => (
<option key={v} value={v}>{tLife(v)}</option>
))}
</select>
</div>
<div>
<Label>{t("criticality")}</Label>
<div className="mt-1"><SegmentedRating name="criticality" defaultValue={p?.criticality ?? 1} form={formId} /></div>
</div>
<fieldset className="md:col-span-2">
<legend className="text-sm font-medium">{t("protection")} (1–4)</legend>
<div className="mt-2 grid grid-cols-3 gap-4">
{(
[
["confidentiality", tA("confidentiality")],
["integrity", tA("integrity")],
["availability", tA("availability")],
] as const
).map(([n, label]) => (
<div key={n}>
<Label>{label}</Label>
<div className="mt-1">
<SegmentedRating name={n} defaultValue={(supplier?.[n] as number) ?? 1} low={tLevel("1")} high={tLevel("4")} form={formId} />
</div>
</div>
))}
</div>
</fieldset>
<div>
<Label htmlFor="nextReview">{t("nextReview")}</Label>
<Input id="nextReview" name="nextReview" type="date" defaultValue={p?.nextReview ? p.nextReview.toISOString().slice(0, 10) : ""} className="mt-1" {...f} />
</div>
<div className="md:col-span-2">
<Label htmlFor="dataCategories">{t("dataCategories")}</Label>
<Input id="dataCategories" name="dataCategories" defaultValue={p?.dataCategories.join(", ") ?? ""} className="mt-1" {...f} />
</div>
<label className="flex items-center gap-2 text-sm md:col-span-2">
<input type="checkbox" name="nis2Relevant" defaultChecked={p?.nis2Relevant} {...f} /> {t("nis2")}
</label>
<div className="md:col-span-2">
<Label htmlFor="notes">{t("notes")}</Label>
<Textarea id="notes" name="notes" rows={2} defaultValue={p?.notes ?? ""} className="mt-1" {...f} />
</div>
</div>
);
}
export async function SupplierCreateModal() {
const t = await getTranslations("suppliers");
const tc = await getTranslations("common");
return (
<Modal title={t("createTitle")} closeHref="/suppliers" closeLabel={t("close")}>
<form action={createSupplier} className="space-y-4 p-5">
<SupplierFields />
<div className="flex gap-2 pt-1">
<Button type="submit">{tc("save")}</Button>
<Button variant="outline" nativeButton={false} render={<Link href="/suppliers" />}>{tc("cancel")}</Button>
</div>
</form>
</Modal>
);
}
/* ─────────────────────── Bearbeiten ─────────────────────── */
export async function SupplierEditModal({ supplier, backHref = "/suppliers" }: { supplier: SupplierAssetDetail; backHref?: string }) {
const t = await getTranslations("suppliers");
const tc = await getTranslations("common");
const tEKind = await getTranslations("evidenceKind");
const tAType = await getTranslations("assessmentType");
const FORM = "supplier-edit";
const detailHref = withQuery(backHref, "detail", supplier.id);
const disc =
"cursor-pointer list-none rounded-lg border border-[var(--panel-brd)] bg-[var(--elevated)] px-3 py-1.5 text-[12.5px] font-semibold text-muted-foreground hover:text-foreground [&::-webkit-details-marker]:hidden";
return (
<Modal
title={t("editTitle")}
sub={supplier.name}
headerExtra={
<details className="relative">
<summary className="grid size-8 cursor-pointer list-none place-items-center rounded-md text-muted-foreground hover:bg-muted [&::-webkit-details-marker]:hidden"><Trash2 className="size-4" /></summary>
<div className="shadow-card absolute right-0 z-20 mt-1 w-48 rounded-xl border bg-card p-1.5">
<form action={deleteSupplier.bind(null, supplier.id, backHref)}>
<button type="submit" className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm text-destructive hover:bg-destructive/10"><Trash2 className="size-4" /> {tc("delete")}</button>
</form>
</div>
</details>
}
closeHref={detailHref}
closeLabel={t("close")}
footer={
<>
<Button variant="outline" nativeButton={false} render={<Link href={detailHref} />}>{tc("cancel")}</Button>
<Button type="submit" form={FORM}>{tc("save")}</Button>
</>
}
>
<form id={FORM} action={updateSupplier.bind(null, supplier.id)} className="hidden">
<input type="hidden" name="returnTo" value={backHref} />
</form>
<div className="space-y-6 p-5">
<SupplierFields supplier={supplier} formId={FORM} />
<ChildSection title={`${t("evidence")}`} items={supplier.evidence.map((e) => ({ id: e.id, label: `${tEKind(e.kind)} · ${e.name ?? ""}${e.adequacyChecked ? " ✓" : ""}` }))} kind="evidence" addLabel={t("addEvidence")} disc={disc}>
<form action={addEvidence.bind(null, supplier.id)} className="shadow-card absolute right-0 z-10 mt-2 w-80 space-y-2 rounded-xl border bg-card p-3 text-sm">
<select name="kind" className={inputCls}>{(["TISAX_LABEL", "AUDIT_REPORT", "CERTIFICATE", "ATTESTATION", "SELF_ASSESSMENT"] as const).map((k) => <option key={k} value={k}>{tEKind(k)}</option>)}</select>
<Input name="name" placeholder={t("name")} />
<Input name="protectsCia" placeholder={t("protectsCia")} defaultValue="C,I,A" />
<Input name="validTo" type="date" />
<label className="flex items-center gap-2 text-[12.5px]"><input type="checkbox" name="adequacyChecked" /> {t("adequacy")}</label>
<Button type="submit" variant="secondary" size="sm">{tc("add")}</Button>
</form>
</ChildSection>
<ChildSection title={t("contracts")} items={supplier.contracts.map((c) => ({ id: c.id, label: `${c.reference ?? c.type}${c.avDpa ? " · AV" : ""}` }))} kind="contract" addLabel={t("addContract")} disc={disc}>
<form action={addContract.bind(null, supplier.id)} className="shadow-card absolute right-0 z-10 mt-2 w-80 space-y-2 rounded-xl border bg-card p-3 text-sm">
<Input name="reference" placeholder={t("reference")} />
<div className="grid grid-cols-2 gap-1 text-[12px]">
<label className="flex items-center gap-1.5"><input type="checkbox" name="avDpa" defaultChecked /> {t("avDpa")}</label>
<label className="flex items-center gap-1.5"><input type="checkbox" name="securityClauses" defaultChecked /> {t("securityClauses")}</label>
<label className="flex items-center gap-1.5"><input type="checkbox" name="flowdown" /> {t("flowdown")}</label>
<label className="flex items-center gap-1.5"><input type="checkbox" name="customerRequirementsPassed" /> {t("customerReq")}</label>
</div>
<Input name="validTo" type="date" />
<Button type="submit" variant="secondary" size="sm">{tc("add")}</Button>
</form>
</ChildSection>
<ChildSection title={t("ndas")} items={supplier.ndas.map((n) => ({ id: n.id, label: n.subject ?? n.parties ?? "NDA" }))} kind="nda" addLabel={t("addNda")} disc={disc}>
<form action={addNda.bind(null, supplier.id)} className="shadow-card absolute right-0 z-10 mt-2 w-80 space-y-2 rounded-xl border bg-card p-3 text-sm">
<Input name="parties" placeholder={t("parties")} />
<Input name="subject" placeholder={t("subject")} />
<Input name="validTo" type="date" />
<Input name="extensionStatus" placeholder={t("extensionStatus")} />
<Button type="submit" variant="secondary" size="sm">{tc("add")}</Button>
</form>
</ChildSection>
<ChildSection title={t("assessments")} items={supplier.assessments.map((a) => ({ id: a.id, label: `${tAType(a.type)}${a.score != null ? ` · ${a.score}/100` : ""}` }))} kind="assessment" addLabel={t("addAssessment")} disc={disc}>
<form action={addAssessment.bind(null, supplier.id)} className="shadow-card absolute right-0 z-10 mt-2 w-80 space-y-2 rounded-xl border bg-card p-3 text-sm">
<select name="type" className={inputCls}>{(["SELF_ASSESSMENT", "QUESTIONNAIRE", "AUDIT"] as const).map((k) => <option key={k} value={k}>{tAType(k)}</option>)}</select>
<Input name="score" type="number" min={0} max={100} placeholder={t("score")} />
<Input name="nextReview" type="date" placeholder={t("nextReview")} />
<Input name="result" placeholder={t("result")} />
<Button type="submit" variant="secondary" size="sm">{tc("add")}</Button>
</form>
</ChildSection>
<ChildSection title={t("decision")} items={supplier.decisions.map((d) => ({ id: d.id, label: d.decision }))} kind="decision" addLabel={t("addDecision")} disc={disc}>
<form action={addDecision.bind(null, supplier.id)} className="shadow-card absolute right-0 z-10 mt-2 w-80 space-y-2 rounded-xl border bg-card p-3 text-sm">
<Textarea name="reasonNoAudit" required rows={2} placeholder={t("reasonNoAudit")} />
<Textarea name="decision" required rows={2} placeholder={t("decisionText")} />
<Input name="recordRef" placeholder={t("recordRef")} />
<Button type="submit" variant="secondary" size="sm">{tc("add")}</Button>
</form>
</ChildSection>
</div>
</Modal>
);
}
async function ChildSection({
title, items, kind, addLabel, disc, children,
}: {
title: string;
items: { id: string; label: string }[];
kind: "contract" | "nda" | "evidence" | "assessment" | "decision" | "subcontractor";
addLabel: string;
disc: string;
children: React.ReactNode;
}) {
const tc = await getTranslations("common");
return (
<section className="border-t pt-4 text-sm">
<div className="flex items-center justify-between gap-2">
<p className="font-heading text-[15px] font-semibold">{title}</p>
<details className="relative">
<summary className={disc}><span className="inline-flex items-center gap-1.5"><Plus className="size-3.5" /> {addLabel}</span></summary>
{children}
</details>
</div>
{items.length > 0 && (
<ul className="mt-2 space-y-1.5">
{items.map((it) => (
<li key={it.id} className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate">{it.label}</span>
<form action={deleteChild.bind(null, kind, it.id)}>
<button type="submit" title={tc("remove")} className="text-muted-foreground hover:text-destructive"><X className="size-3.5" /></button>
</form>
</li>
))}
</ul>
)}
</section>
);
}
-364
View File
@@ -1,364 +0,0 @@
import Link from "next/link";
import { Pencil, Trash2, Check, X, Paperclip, ShieldCheck } from "lucide-react";
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 { updateTask, deleteTask, createOwnTask, approveTask, rejectTask, commentTask, createEvidence, linkEvidence, unlinkEvidence } from "@/server/actions/tasks";
import { TASK_TYPE_LABELS, EVIDENCE_KINDS, EVIDENCE_KIND_LABELS, type TaskType, type TaskLinks, type EvidenceKind } from "@/lib/tasks";
/**
* Aufgaben-Popups (Detail/Bearbeiten/Anlegen) — bewusst analog zu den Maßnahmen-Modals
* (measure-modals.tsx), damit Aufgaben und Maßnahmen dieselbe Bedienung haben. Öffnen/
* Schließen über searchParams der Aufgaben-Seite (?detail=/?edit=/?new=task).
*/
export interface TaskComment {
id: string;
authorId: string | null;
kind: string;
body: string;
createdAt: Date;
}
export interface EvidenceItem {
id: string;
title: string;
kind: string;
fileRef: string | null;
control: string | null;
validFrom: Date | null;
validUntil: Date | null;
}
export interface TaskDetail {
id: string;
type: string;
title: string;
description: string | null;
status: string;
priority: string;
dueDate: Date | null;
assigneeId: string | null;
createdById: string | null;
links: unknown;
updatedAt: Date;
comments?: TaskComment[];
evidence?: EvidenceItem[];
}
const STATUS_LABEL: Record<string, string> = { PROPOSED: "Vorschlag", OPEN: "Offen", IN_PROGRESS: "In Umsetzung", DONE: "Erledigt", REJECTED: "Abgelehnt", CANCELLED: "Abgebrochen", DISCARDED: "Verworfen" };
const STATUS_TONE: Record<string, "ok" | "info" | "warn" | "mut"> = { PROPOSED: "warn", OPEN: "info", IN_PROGRESS: "warn", DONE: "ok", REJECTED: "warn", CANCELLED: "mut", DISCARDED: "mut" };
const KIND_LABEL: Record<string, string> = { submit: "übernommen", approve: "freigegeben", reject: "abgelehnt", comment: "Kommentar" };
const PRIO_LABEL: Record<string, string> = { hoch: "Hoch", mittel: "Mittel", niedrig: "Niedrig" };
const PRIO_TONE: Record<string, "risk" | "warn" | "mut"> = { hoch: "risk", mittel: "warn", niedrig: "mut" };
const EDIT_STATUSES = ["OPEN", "IN_PROGRESS", "DONE", "CANCELLED"] as const;
const PRIORITIES = ["hoch", "mittel", "niedrig"] as const;
// Manuell anlegbare Aufgabentypen (Review-/Freigabe-Typen entstehen aus den Fachmodulen).
const CREATABLE_TYPES: TaskType[] = ["organizational", "technical", "document_create", "evidence_provide"];
function typeLabel(type: string): string {
return TASK_TYPE_LABELS[type as TaskType] ?? type;
}
function linkRef(links: unknown): string | null {
const l = (links ?? {}) as TaskLinks;
const parts = [l.control && `Control ${l.control}`, l.document, l.risk && `Risiko ${l.risk}`, l.asset && `Asset ${l.asset}`].filter(Boolean);
return parts.length ? parts.join(" · ") : null;
}
function controlFromLinks(links: unknown): string | null {
return ((links ?? {}) as TaskLinks).control ?? null;
}
const selectClass = "h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm";
const EVIDENCE_KIND_TONE: Record<string, "ok" | "info" | "warn" | "mut"> = {
record: "info", protocol: "info", screenshot: "mut", export: "mut",
};
/**
* Nachweis-/Evidence-Register einer Aufgabe (M3, 1.6). Zeigt verknüpfte Nachweise
* (Art + Wirksamkeit mit Ablauf-Markierung), erlaubt Anlegen/Verknüpfen/Entfernen.
* Der Nachweis ist die Voraussetzung für „audit-ready" (Wirksamkeit, 2.2).
*/
function EvidenceSection({
taskId,
evidence,
linkable,
defaultControl,
canManage,
}: {
taskId: string;
evidence: EvidenceItem[];
linkable: { id: string; title: string; kind: string }[];
defaultControl: string | null;
canManage: boolean;
}) {
const now = new Date();
return (
<div className="rounded-xl border bg-[var(--surface-soft)] p-4">
<div className="flex items-center gap-2">
<ShieldCheck className="size-4 text-[var(--primary)]" />
<b className="text-[13px]">Nachweise</b>
<span className="text-[11px] text-muted-foreground">Voraussetzung für „audit-ready“ (Wirksamkeit)</span>
</div>
{evidence.length === 0 ? (
<p className="mt-2 text-[12px] text-muted-foreground">Noch kein Nachweis verknüpft.</p>
) : (
<ul className="mt-2 space-y-1.5">
{evidence.map((e) => {
const expired = !!e.validUntil && e.validUntil < now;
return (
<li key={e.id} className="flex flex-wrap items-center gap-2 rounded-md border bg-card px-2.5 py-1.5 text-[12px]">
<Pill tone={EVIDENCE_KIND_TONE[e.kind] ?? "mut"}>{EVIDENCE_KIND_LABELS[e.kind as EvidenceKind] ?? e.kind}</Pill>
{e.fileRef ? (
<a href={e.fileRef} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 font-medium hover:underline">
<Paperclip className="size-3" /> {e.title}
</a>
) : (
<span className="font-medium">{e.title}</span>
)}
{e.control && <span className="text-muted-foreground">Control {e.control}</span>}
{e.validUntil && (
expired
? <Pill tone="risk">abgelaufen {e.validUntil.toLocaleDateString("de-DE")}</Pill>
: <span className="text-muted-foreground">gültig bis {e.validUntil.toLocaleDateString("de-DE")}</span>
)}
{canManage && (
<form action={unlinkEvidence.bind(null, e.id)} className="ml-auto">
<Button type="submit" variant="ghost" size="sm" title="Nachweis entfernen"><X className="size-3.5" /></Button>
</form>
)}
</li>
);
})}
</ul>
)}
{canManage && (
<>
<form action={createEvidence.bind(null, taskId)} className="mt-3 space-y-2 border-t pt-3">
<div className="grid gap-2 sm:grid-cols-2">
<Input name="title" placeholder="Titel des Nachweises" required className="h-8" />
<select name="kind" defaultValue="record" className={`${selectClass} h-8`}>
{EVIDENCE_KINDS.map((k) => <option key={k} value={k}>{EVIDENCE_KIND_LABELS[k]}</option>)}
</select>
</div>
<Input name="fileRef" placeholder="Verweis/URL zur Ablage (optional)" className="h-8" />
<div className="grid gap-2 sm:grid-cols-3">
<Input name="control" defaultValue={defaultControl ?? ""} placeholder="Control (optional)" className="h-8" />
<label className="text-[11px] text-muted-foreground">Gültig ab
<Input type="date" name="validFrom" className="mt-0.5 h-8" />
</label>
<label className="text-[11px] text-muted-foreground">Gültig bis
<Input type="date" name="validUntil" className="mt-0.5 h-8" />
</label>
</div>
<Button type="submit" size="sm"><Paperclip className="size-4" /> Nachweis anlegen &amp; verknüpfen</Button>
</form>
{linkable.length > 0 && (
<form action={linkEvidence.bind(null, taskId)} className="mt-2 flex gap-2">
<select name="evidenceId" className={`${selectClass} h-8`}>
{linkable.map((e) => (
<option key={e.id} value={e.id}>{(EVIDENCE_KIND_LABELS[e.kind as EvidenceKind] ?? e.kind)} · {e.title}</option>
))}
</select>
<Button type="submit" variant="outline" size="sm">Bestehenden verknüpfen</Button>
</form>
)}
</>
)}
</div>
);
}
/** Aufgaben-Detail als Popup: Fakten, Kommentare, Freigabe/Ablehnung und Bearbeiten. */
export function TaskDetailModal({
task,
me,
ownerName,
name,
canManage,
linkableEvidence = [],
}: {
task: TaskDetail;
me: string;
ownerName: string;
name: (id: string | null) => string;
canManage: boolean;
linkableEvidence?: { id: string; title: string; kind: string }[];
}) {
const ref = linkRef(task.links);
const comments = task.comments ?? [];
const isAssignee = task.assigneeId === me;
const isParticipant = isAssignee || task.createdById === me;
const isApproval = task.type === "policy_approval" && task.status === "OPEN";
return (
<Modal
title={task.title}
sub={typeLabel(task.type)}
headerExtra={
<span className="flex items-center gap-2">
<Pill tone={PRIO_TONE[task.priority] ?? "mut"}>{PRIO_LABEL[task.priority] ?? task.priority}</Pill>
<Pill tone={STATUS_TONE[task.status] ?? "mut"}>{STATUS_LABEL[task.status] ?? task.status}</Pill>
</span>
}
closeHref="/tasks"
closeLabel="Schließen"
footer={
<>
{canManage && (
<Button variant="outline" nativeButton={false} render={<Link href={`/tasks?edit=${task.id}`} />}>
<Pencil className="size-4" /> Bearbeiten
</Button>
)}
<Button nativeButton={false} render={<Link href="/tasks" />}>Schließen</Button>
</>
}
>
<div className="space-y-4 p-5">
<div className="rounded-xl border border-l-[3px] border-l-[var(--primary)] bg-[var(--surface-soft)] p-4">
<b>{task.title}</b>
{task.description && <p className="mt-1.5 text-[12.5px] leading-relaxed whitespace-pre-line">{task.description}</p>}
<dl className="mt-3 grid grid-cols-[7.5rem_1fr] gap-1.5 text-[12.5px]">
<dt className="text-muted-foreground">Owner</dt>
<dd>{ownerName}</dd>
<dt className="text-muted-foreground">Fällig</dt>
<dd>{task.dueDate ? task.dueDate.toLocaleDateString("de-DE") : "—"}</dd>
{ref && <><dt className="text-muted-foreground">Bezug</dt><dd>{ref}</dd></>}
</dl>
</div>
{/* Freigabe/Ablehnung nur für Freigabe-Aufgaben durch den zugewiesenen Freigeber. */}
{isApproval && isAssignee && (
<div className="grid gap-2 sm:grid-cols-2">
<form action={approveTask.bind(null, task.id)} className="flex gap-2">
<Input name="note" placeholder="Kommentar (optional)" className="h-8" />
<Button type="submit" size="sm"><Check className="size-4" /> Freigeben</Button>
</form>
<form action={rejectTask.bind(null, task.id)} className="flex gap-2">
<Input name="note" placeholder="Grund (erforderlich)" required className="h-8" />
<Button type="submit" variant="outline" size="sm"><X className="size-4" /> Ablehnen</Button>
</form>
</div>
)}
{isApproval && !isAssignee && (
<p className="text-[11.5px] text-muted-foreground">Wartet auf Freigabe durch {name(task.assigneeId)}.</p>
)}
<EvidenceSection
taskId={task.id}
evidence={task.evidence ?? []}
linkable={linkableEvidence}
defaultControl={controlFromLinks(task.links)}
canManage={canManage}
/>
{comments.length > 0 && (
<ul className="space-y-1.5 border-t pt-3">
{comments.map((c) => (
<li key={c.id} className="text-[12.5px]">
<span className="text-muted-foreground">{name(c.authorId)} · {KIND_LABEL[c.kind] ?? c.kind}:</span> {c.body}
</li>
))}
</ul>
)}
{isParticipant && (
<form action={commentTask.bind(null, task.id)} className="flex gap-2">
<Input name="body" placeholder="Kommentar hinzufügen…" className="h-8" />
<Button type="submit" variant="ghost" size="sm">Kommentieren</Button>
</form>
)}
</div>
</Modal>
);
}
/** Formularfelder für Anlegen/Bearbeiten. */
function TaskFields({ task, users, withStatus }: { task?: TaskDetail; users: { id: string; name: string | null }[]; withStatus?: boolean }) {
return (
<>
<div>
<Label htmlFor="title">Titel</Label>
<Input id="title" name="title" required defaultValue={task?.title} className="mt-1" />
</div>
<div>
<Label htmlFor="description">Beschreibung</Label>
<Textarea id="description" name="description" rows={3} defaultValue={task?.description ?? ""} className="mt-1" />
</div>
<div className="grid grid-cols-2 gap-4">
{withStatus && (
<div>
<Label htmlFor="status">Status</Label>
<select id="status" name="status" defaultValue={task?.status ?? "OPEN"} className={`${selectClass} mt-1`}>
{EDIT_STATUSES.map((v) => <option key={v} value={v}>{STATUS_LABEL[v]}</option>)}
</select>
</div>
)}
<div>
<Label htmlFor="priority">Priorität</Label>
<select id="priority" name="priority" defaultValue={task?.priority ?? "mittel"} className={`${selectClass} mt-1`}>
{PRIORITIES.map((v) => <option key={v} value={v}>{PRIO_LABEL[v]}</option>)}
</select>
</div>
<div>
<Label htmlFor="owner">Owner</Label>
<select id="owner" name="owner" defaultValue={task?.assigneeId ?? ""} className={`${selectClass} mt-1`}>
<option value="">— nicht zugewiesen —</option>
{users.map((u) => <option key={u.id} value={u.id}>{u.name ?? u.id}</option>)}
</select>
</div>
<div>
<Label htmlFor="dueDate">Fällig bis</Label>
<Input id="dueDate" name="dueDate" type="date" defaultValue={task?.dueDate ? task.dueDate.toISOString().slice(0, 10) : ""} className="mt-1" />
</div>
</div>
</>
);
}
/** Aufgabe bearbeiten als Popup (inkl. Löschen). */
export function TaskEditModal({ task, users }: { task: TaskDetail; users: { id: string; name: string | null }[] }) {
return (
<Modal title="Aufgabe bearbeiten" sub={task.title} closeHref={`/tasks?detail=${task.id}`} closeLabel="Schließen">
<div className="p-5">
<form key={task.updatedAt.toISOString()} action={updateTask.bind(null, task.id)} className="space-y-4">
<TaskFields task={task} users={users} withStatus />
<div className="flex gap-2">
<Button type="submit">Speichern</Button>
<Button variant="outline" nativeButton={false} render={<Link href={`/tasks?detail=${task.id}`} />}>Abbrechen</Button>
</div>
</form>
<div className="mt-5 border-t pt-4">
<form action={deleteTask.bind(null, task.id)}>
<Button type="submit" variant="destructive" size="sm"><Trash2 className="size-4" /> Löschen</Button>
</form>
</div>
</div>
</Modal>
);
}
/** Neue Aufgabe anlegen als Popup. */
export function TaskCreateModal({ users }: { users: { id: string; name: string | null }[] }) {
return (
<Modal title="Neue Aufgabe" sub="Für Maßnahmen (z. B. zu Risiken) nutzen Sie „Neue Maßnahme“." closeHref="/tasks" closeLabel="Schließen">
<form action={createOwnTask} className="space-y-4 p-5">
<div>
<Label htmlFor="type">Art</Label>
<select id="type" name="type" defaultValue="organizational" className={`${selectClass} mt-1`}>
{CREATABLE_TYPES.map((v) => <option key={v} value={v}>{typeLabel(v)}</option>)}
</select>
</div>
<TaskFields users={users} />
<div className="flex gap-2 pt-1">
<Button type="submit">Speichern</Button>
<Button variant="outline" nativeButton={false} render={<Link href="/tasks" />}>Abbrechen</Button>
</div>
</form>
</Modal>
);
}