IT-Service-Detail auf Asset-Grundgerüst + Schutzbedarf-Layout gefixt

- ServiceDetailModal spiegelt jetzt das normale Asset-Detail: links
  "■ Stammdaten" (violette Karte, CIA-Badge, Provider/Kritikalität, Notizen,
  zugeordnete Prozesse), rechts "◆ Abhängigkeiten" (Verknüpfte-Assets-Tabelle
  mit Typ + CIA + CiaLegend, Reverse-Liste, Link zum Abhängigkeitsgraph),
  darunter das Risiken-Band — und erst darunter die Verantwortungsmatrix (RACI)
- SERVICE_INCLUDE um Typ/CIA der verknüpften Assets und Risk-Score erweitert,
  RACI nach controlRef sortiert
- Schutzbedarf (C/I/A) in Lieferanten- und IT-Service-Bearbeiten-Formular:
  vom gequetschten flex-Layout auf das saubere Asset-Formular-Muster umgestellt
  (grid mit Einzellabels Vertraulichkeit/Integrität/Verfügbarkeit + Normal…Sehr hoch)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 16:31:58 +02:00
co-authored by Claude Opus 4.8
parent d994c94ebb
commit ee4e554554
3 changed files with 162 additions and 60 deletions
+3 -3
View File
@@ -56,9 +56,9 @@ const SUPPLIER_INCLUDE = {
const SERVICE_INCLUDE = { const SERVICE_INCLUDE = {
serviceProfile: { include: { provider: { select: { id: true, name: true } } } }, serviceProfile: { include: { provider: { select: { id: true, name: true } } } },
raci: true, raci: { orderBy: { controlRef: "asc" } },
riskAssets: { include: { risk: { select: { id: true, refNo: true, title: true } } } }, riskAssets: { include: { risk: { select: { id: true, refNo: true, title: true, score: true } } } },
relationsFrom: { include: { relatedAsset: { select: { id: true, name: true } } } }, relationsFrom: { include: { relatedAsset: { select: { id: true, name: true, type: true, confidentiality: true, integrity: true, availability: true } } } },
relationsTo: { include: { asset: { select: { id: true, name: true } } } }, relationsTo: { include: { asset: { select: { id: true, name: true } } } },
processAssets: { include: { process: { select: { id: true, name: true } } } }, processAssets: { include: { process: { select: { id: true, name: true } } } },
} as const; } as const;
+136 -47
View File
@@ -1,6 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { Pencil, Plus, Trash2 } from "lucide-react"; import { Network, Pencil, Plus, Trash2 } from "lucide-react";
import type { Prisma } from "@prisma/client"; import type { Prisma } from "@prisma/client";
import { import {
addRaci, addRaci,
@@ -16,16 +16,17 @@ import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Modal } from "@/components/modal"; import { Modal } from "@/components/modal";
import { SegmentedRating } from "@/components/segmented-rating"; import { SegmentedRating } from "@/components/segmented-rating";
import { CiaBadge, CriticalityPill, Pill } from "@/components/mockup-ui"; import { CiaBadge, CiaLegend, CriticalityPill, Pill, Tag } from "@/components/mockup-ui";
import { serviceRef } from "@/lib/supplier"; import { serviceRef } from "@/lib/supplier";
import { riskLevel, riskRef, RISK_PILL_TONE } from "@/lib/risk";
import { ISA_CONTROLS } from "@/lib/isa-controls"; import { ISA_CONTROLS } from "@/lib/isa-controls";
export type ServiceAssetDetail = Prisma.AssetGetPayload<{ export type ServiceAssetDetail = Prisma.AssetGetPayload<{
include: { include: {
serviceProfile: { include: { provider: { select: { id: true; name: true } } } }; serviceProfile: { include: { provider: { select: { id: true; name: true } } } };
raci: true; raci: true;
riskAssets: { include: { risk: { select: { id: true; refNo: true; title: true } } } }; riskAssets: { include: { risk: { select: { id: true; refNo: true; title: true; score: true } } } };
relationsFrom: { include: { relatedAsset: { select: { id: true; name: true } } } }; relationsFrom: { include: { relatedAsset: { select: { id: true; name: true; type: true; confidentiality: true; integrity: true; availability: true } } } };
relationsTo: { include: { asset: { select: { id: true; name: true } } } }; relationsTo: { include: { asset: { select: { id: true; name: true } } } };
processAssets: { include: { process: { select: { id: true; name: true } } } }; processAssets: { include: { process: { select: { id: true; name: true } } } };
}; };
@@ -38,23 +39,29 @@ const RACI_TONE = { PROVIDER: "info", US: "violet", SHARED: "warn" } as const;
export async function ServiceDetailModal({ service, canWrite }: { service: ServiceAssetDetail; canWrite: boolean }) { export async function ServiceDetailModal({ service, canWrite }: { service: ServiceAssetDetail; canWrite: boolean }) {
const t = await getTranslations("services"); const t = await getTranslations("services");
const ta = await getTranslations("assets");
const tParty = await getTranslations("raciParty"); const tParty = await getTranslations("raciParty");
const tCrit = await getTranslations("criticality"); 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 tc = await getTranslations("common");
const tp = await getTranslations("processes");
const p = service.serviceProfile!; const p = service.serviceProfile!;
return ( return (
<Modal <Modal
title={`${serviceRef(p.refNo)} · ${service.name}`} title={service.name}
sub={t("detailSub")} sub={t("detailSub")}
headerExtra={ headerExtra={
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<CiaBadge c={service.confidentiality} i={service.integrity} a={service.availability} /> <Tag>{tType(service.type)}</Tag>
<CriticalityPill level={p.criticality} label={tCrit(String(p.criticality))} /> <CriticalityPill level={p.criticality} label={tCrit(String(p.criticality))} />
</span> </span>
} }
closeHref="/suppliers?tab=services" closeHref="/suppliers?tab=services"
closeLabel={t("close")} closeLabel={tp("close")}
footer={ footer={
<> <>
{canWrite && ( {canWrite && (
@@ -62,54 +69,123 @@ export async function ServiceDetailModal({ service, canWrite }: { service: Servi
<Pencil className="size-4" /> {tc("edit")} <Pencil className="size-4" /> {tc("edit")}
</Button> </Button>
)} )}
<Button nativeButton={false} render={<Link href="/suppliers?tab=services" />}>{t("close")}</Button> <Button nativeButton={false} render={<Link href="/suppliers?tab=services" />}>{tp("close")}</Button>
</> </>
} }
> >
<div className="space-y-5 p-5"> <div className="grid gap-5 p-5 md:grid-cols-2">
{/* Stammdaten + Verknüpfungen */} {/* Stammdaten — violette Karte wie beim Asset-Detail */}
<div className="grid gap-4 md:grid-cols-3"> <div>
<div className="rounded-xl border border-l-[3px] border-l-[var(--primary)] bg-[var(--surface-soft)] p-4 text-[12.5px] md:col-span-2"> <div className="mb-2.5 flex items-center gap-2.5">
<dl className="grid grid-cols-[8rem_1fr] gap-1.5"> <Pill tone="violet">{ta("masterPill")}</Pill>
<dt className="text-muted-foreground">{t("provider")}</dt> <span className="text-[12.5px] text-muted-foreground">{ta("masterNote")}</span>
<dd> </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 ? ( {p.provider ? (
<Link href={`/suppliers?detail=${p.provider.id}`} className="hover:underline">{p.provider.name}</Link> <Link href={`/suppliers?detail=${p.provider.id}`} className="hover:underline">{p.provider.name}</Link>
) : ( ) : (
<span className="text-muted-foreground">{p.internal ? t("internal") : t("noProvider")}</span> p.internal ? t("internal") : t("noProvider")
)} )}
</dd> {" · "}
<dt className="text-muted-foreground">{t("linkedAssets")}</dt> {t("criticality")}: {tCrit(String(p.criticality))}
<dd className="flex flex-wrap gap-1">
{[...service.relationsFrom.map((r) => r.relatedAsset), ...service.relationsTo.map((r) => r.asset)].map((a) => (
<Link key={a.id} href={`/assets?detail=${a.id}`} className="rounded bg-[var(--elevated)] px-1.5 py-0.5 hover:underline">{a.name}</Link>
))}
{service.relationsFrom.length + service.relationsTo.length === 0 && tc("none")}
</dd>
<dt className="text-muted-foreground">{t("linkedProcesses")}</dt>
<dd className="flex flex-wrap gap-1">
{service.processAssets.map((pa) => (
<Link key={pa.id} href={`/processes?detail=${pa.process.id}`} className="rounded bg-[var(--elevated)] px-1.5 py-0.5 hover:underline">{pa.process.name}</Link>
))}
{service.processAssets.length === 0 && tc("none")}
</dd>
</dl>
{p.notes && <p className="mt-2 text-muted-foreground">{p.notes}</p>}
</div> </div>
<div className="rounded-xl border p-4"> {p.notes && <p className="mt-2 text-[12.5px] leading-relaxed">{p.notes}</p>}
<p className="text-sm font-semibold">{t("risks")}</p> </div>
{service.riskAssets.length === 0 && <p className="mt-1 text-sm text-muted-foreground">{tc("none")}</p>}
<ul className="mt-1.5 space-y-1 text-sm"> <div className="mt-4">
{service.riskAssets.map((ra) => ( <p className="text-sm font-semibold">{ta("processes")}</p>
<li key={ra.id}> {service.processAssets.length === 0 && <p className="mt-1 text-sm text-muted-foreground">{tc("none")}</p>}
<Link href={`/risks?detail=${ra.risk.id}`} className="hover:underline">R-{String(ra.risk.refNo).padStart(3, "0")} · {ra.risk.title}</Link> <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> </li>
))} ))}
</ul> </ul>
</div> </div>
</div> </div>
{/* RACI-Matrix */} {/* 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"> <section className="rounded-xl border">
<div className="flex items-center justify-between gap-2 p-4 pb-2"> <div className="flex items-center justify-between gap-2 p-4 pb-2">
<div> <div>
@@ -189,6 +265,8 @@ export async function ServiceDetailModal({ service, canWrite }: { service: Servi
async function ServiceFields({ service, providers, formId }: { service?: ServiceAssetDetail; providers: { id: string; name: string }[]; formId?: string }) { async function ServiceFields({ service, providers, formId }: { service?: ServiceAssetDetail; providers: { id: string; name: string }[]; formId?: string }) {
const t = await getTranslations("services"); const t = await getTranslations("services");
const tA = await getTranslations("assets");
const tLevel = await getTranslations("protectionLevel");
const f = formId ? { form: formId } : {}; const f = formId ? { form: formId } : {};
const p = service?.serviceProfile; const p = service?.serviceProfile;
return ( return (
@@ -210,14 +288,25 @@ async function ServiceFields({ service, providers, formId }: { service?: Service
<Label>{t("criticality")}</Label> <Label>{t("criticality")}</Label>
<div className="mt-1"><SegmentedRating name="criticality" defaultValue={p?.criticality ?? 1} form={formId} /></div> <div className="mt-1"><SegmentedRating name="criticality" defaultValue={p?.criticality ?? 1} form={formId} /></div>
</div> </div>
<div className="md:col-span-2"> <fieldset className="md:col-span-2">
<Label>{t("protection")} (C/I/A)</Label> <legend className="text-sm font-medium">{t("protection")} (14)</legend>
<div className="mt-1 flex gap-2"> <div className="mt-2 grid grid-cols-3 gap-4">
{(["confidentiality", "integrity", "availability"] as const).map((n) => ( {(
<SegmentedRating key={n} name={n} defaultValue={(service?.[n] as number) ?? 1} form={formId} /> [
["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> </div>
</div> </fieldset>
<label className="flex items-center gap-2 text-sm md:col-span-2"> <label className="flex items-center gap-2 text-sm md:col-span-2">
<input type="checkbox" name="internal" defaultChecked={p?.internal} {...f} /> {t("internal")} <input type="checkbox" name="internal" defaultChecked={p?.internal} {...f} /> {t("internal")}
</label> </label>
+19 -6
View File
@@ -221,6 +221,8 @@ export async function SupplierDetailModal({ supplier, canWrite }: { supplier: Su
async function SupplierFields({ supplier, formId }: { supplier?: SupplierAssetDetail; formId?: string }) { async function SupplierFields({ supplier, formId }: { supplier?: SupplierAssetDetail; formId?: string }) {
const t = await getTranslations("suppliers"); const t = await getTranslations("suppliers");
const tLife = await getTranslations("supplierStatus"); const tLife = await getTranslations("supplierStatus");
const tA = await getTranslations("assets");
const tLevel = await getTranslations("protectionLevel");
const f = formId ? { form: formId } : {}; const f = formId ? { form: formId } : {};
const p = supplier?.supplierProfile; const p = supplier?.supplierProfile;
return ( return (
@@ -253,14 +255,25 @@ async function SupplierFields({ supplier, formId }: { supplier?: SupplierAssetDe
<Label>{t("criticality")}</Label> <Label>{t("criticality")}</Label>
<div className="mt-1"><SegmentedRating name="criticality" defaultValue={p?.criticality ?? 1} form={formId} /></div> <div className="mt-1"><SegmentedRating name="criticality" defaultValue={p?.criticality ?? 1} form={formId} /></div>
</div> </div>
<div> <fieldset className="md:col-span-2">
<Label>{t("protection")} (C/I/A)</Label> <legend className="text-sm font-medium">{t("protection")} (14)</legend>
<div className="mt-1 flex gap-2"> <div className="mt-2 grid grid-cols-3 gap-4">
{(["confidentiality", "integrity", "availability"] as const).map((n) => ( {(
<SegmentedRating key={n} name={n} defaultValue={(supplier?.[n] as number) ?? 1} form={formId} /> [
["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> </div>
</div> </fieldset>
<div> <div>
<Label htmlFor="nextReview">{t("nextReview")}</Label> <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} /> <Input id="nextReview" name="nextReview" type="date" defaultValue={p?.nextReview ? p.nextReview.toISOString().slice(0, 10) : ""} className="mt-1" {...f} />