IT-Service-Detail mit RACI-Matrix (VDA-ISA 6.1.3)

- IT-Services als Assets (type IT_SERVICE) mit ITServiceProfile, Provider-Verknüpfung
- Detail-Cockpit: Stammdaten, verknüpfte Assets/Prozesse, Risiken
- RACI-/Shared-Responsibility-Matrix über den ISA-Control-Katalog
  (Control hinzufügen via datalist, Verantwortung per Klick durchschalten,
   PROVIDER/US/SHARED, Nachweis-Referenz, Löschen)
- Lieferanten/IT-Services als Pillen-Tabs (FilterTabs) auf /suppliers
- Server-Actions services.ts (create/update/delete/addRaci/cycleRaci/deleteRaci)
  mit Zod, RBAC (supplier:write) und Audit-Log
- ISA_CONTROLS-Vorschlagsliste (isa-controls.ts)
- Demo-IT-Service "Managed ERP-Hosting" mit 5 RACI-Zeilen im Seed
- de/en Übersetzungen (services, raciParty)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 14:06:04 +02:00
co-authored by Claude Opus 4.8
parent dc884062b2
commit d994c94ebb
7 changed files with 653 additions and 18 deletions
+281
View File
@@ -0,0 +1,281 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { 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, CriticalityPill, Pill } from "@/components/mockup-ui";
import { serviceRef } from "@/lib/supplier";
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 } } } };
relationsFrom: { include: { relatedAsset: { select: { id: true; name: 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 }: { service: ServiceAssetDetail; canWrite: boolean }) {
const t = await getTranslations("services");
const tParty = await getTranslations("raciParty");
const tCrit = await getTranslations("criticality");
const tc = await getTranslations("common");
const p = service.serviceProfile!;
return (
<Modal
title={`${serviceRef(p.refNo)} · ${service.name}`}
sub={t("detailSub")}
headerExtra={
<span className="flex items-center gap-2">
<CiaBadge c={service.confidentiality} i={service.integrity} a={service.availability} />
<CriticalityPill level={p.criticality} label={tCrit(String(p.criticality))} />
</span>
}
closeHref="/suppliers?tab=services"
closeLabel={t("close")}
footer={
<>
{canWrite && (
<Button variant="outline" nativeButton={false} render={<Link href={`/suppliers?tab=services&edit=${service.id}`} />}>
<Pencil className="size-4" /> {tc("edit")}
</Button>
)}
<Button nativeButton={false} render={<Link href="/suppliers?tab=services" />}>{t("close")}</Button>
</>
}
>
<div className="space-y-5 p-5">
{/* Stammdaten + Verknüpfungen */}
<div className="grid gap-4 md:grid-cols-3">
<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">
<dl className="grid grid-cols-[8rem_1fr] gap-1.5">
<dt className="text-muted-foreground">{t("provider")}</dt>
<dd>
{p.provider ? (
<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>
)}
</dd>
<dt className="text-muted-foreground">{t("linkedAssets")}</dt>
<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 className="rounded-xl border p-4">
<p className="text-sm font-semibold">{t("risks")}</p>
{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">
{service.riskAssets.map((ra) => (
<li key={ra.id}>
<Link href={`/risks?detail=${ra.risk.id}`} className="hover:underline">R-{String(ra.risk.refNo).padStart(3, "0")} · {ra.risk.title}</Link>
</li>
))}
</ul>
</div>
</div>
{/* RACI-Matrix */}
<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 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>
<div className="md:col-span-2">
<Label>{t("protection")} (C/I/A)</Label>
<div className="mt-1 flex gap-2">
{(["confidentiality", "integrity", "availability"] as const).map((n) => (
<SegmentedRating key={n} name={n} defaultValue={(service?.[n] as number) ?? 1} form={formId} />
))}
</div>
</div>
<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 }: { service: ServiceAssetDetail; providers: { id: string; name: string }[] }) {
const t = await getTranslations("services");
const tc = await getTranslations("common");
const FORM = "service-edit";
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)}>
<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={`/suppliers?tab=services&detail=${service.id}`}
closeLabel={t("close")}
footer={
<>
<Button variant="outline" nativeButton={false} render={<Link href={`/suppliers?tab=services&detail=${service.id}`} />}>{tc("cancel")}</Button>
<Button type="submit" form={FORM}>{tc("save")}</Button>
</>
}
>
<form id={FORM} action={updateService.bind(null, service.id)} className="hidden" />
<div className="p-5">
<ServiceFields service={service} providers={providers} formId={FORM} />
</div>
</Modal>
);
}