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