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
+117 -18
View File
@@ -5,15 +5,23 @@ import { requireSession } from "@/server/auth";
import { dbForTenant } from "@/server/db";
import { hasPermission, requirePermission } from "@/server/rbac";
import { Button } from "@/components/ui/button";
import { PageHead, Pill } from "@/components/mockup-ui";
import { PageHead, Pill, CriticalityPill, CiaBadge } from "@/components/mockup-ui";
import { FilterTabs } from "@/components/filter-tabs";
import {
SupplierCreateModal,
SupplierDetailModal,
SupplierEditModal,
type SupplierAssetDetail,
} from "@/components/supplier-modals";
import {
ServiceCreateModal,
ServiceDetailModal,
ServiceEditModal,
type ServiceAssetDetail,
} from "@/components/service-modals";
import {
supplierRef,
serviceRef,
protectionLevel,
PROTECTION_LABEL,
buildReqContext,
@@ -32,7 +40,7 @@ import {
TableRow,
} from "@/components/ui/table";
const DETAIL_INCLUDE = {
const SUPPLIER_INCLUDE = {
supplierProfile: true,
contracts: true,
ndas: true,
@@ -46,23 +54,125 @@ const DETAIL_INCLUDE = {
relationsTo: { include: { asset: { select: { name: true, confidentiality: true, integrity: true, availability: true } } } },
} as const;
const SERVICE_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 } } } },
} as const;
export default async function SuppliersPage({
searchParams,
}: {
searchParams: Promise<{ detail?: string; edit?: string; new?: string }>;
searchParams: Promise<{ tab?: string; detail?: string; edit?: string; new?: string }>;
}) {
const session = await requireSession();
requirePermission(session, "supplier:read");
const t = await getTranslations("suppliers");
const ts = await getTranslations("services");
const tCrit = await getTranslations("criticality");
const tc = await getTranslations("common");
const params = await searchParams;
const isServices = params.tab === "services";
const db = dbForTenant(session.user.tenantId);
const canWrite = hasPermission(session, "supplier:write");
const tabs = [
{ href: "/suppliers", label: ts("tabSuppliers"), active: !isServices },
{ href: "/suppliers?tab=services", label: ts("tabServices"), active: isServices },
];
const head = (
<PageHead
crumb={t("crumb")}
title={t("title")}
sub={t("sub")}
actions={
canWrite && (
<Button nativeButton={false} render={<Link href={isServices ? "/suppliers?tab=services&new=1" : "/suppliers?new=1"} />}>
<Plus className="size-4" /> {isServices ? ts("newService") : t("newSupplier")}
</Button>
)
}
/>
);
/* ── IT-Services ── */
if (isServices) {
const services = (await db.asset.findMany({
where: { type: "IT_SERVICE", serviceProfile: { isNot: null } },
include: SERVICE_INCLUDE,
orderBy: { serviceProfile: { refNo: "asc" } },
take: 300,
})) as ServiceAssetDetail[];
const providers = await db.asset.findMany({
where: { type: "SUPPLIER", supplierProfile: { isNot: null } },
select: { id: true, name: true },
orderBy: { name: "asc" },
});
const modalId = params.edit && canWrite ? params.edit : params.detail;
const modalService = modalId
? ((await db.asset.findUnique({ where: { id: modalId }, include: SERVICE_INCLUDE })) as ServiceAssetDetail | null)
: null;
return (
<main className="flex-1 p-6">
{head}
<div className="mt-3"><FilterTabs tabs={tabs} /></div>
<div className="shadow-card mt-4 rounded-xl border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>{ts("ref")}</TableHead>
<TableHead>{ts("name")}</TableHead>
<TableHead>{ts("provider")}</TableHead>
<TableHead>{ts("protection")}</TableHead>
<TableHead>{ts("criticality")}</TableHead>
<TableHead>{ts("raciCoverage")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{services.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">{ts("empty")}</TableCell>
</TableRow>
)}
{services.map((s) => (
<TableRow key={s.id}>
<TableCell className="text-muted-foreground">{serviceRef(s.serviceProfile!.refNo)}</TableCell>
<TableCell>
<Link href={`/suppliers?tab=services&detail=${s.id}`} className="font-bold hover:underline">{s.name}</Link>
</TableCell>
<TableCell className="text-muted-foreground">{s.serviceProfile?.provider?.name ?? (s.serviceProfile?.internal ? ts("internal") : tc("none"))}</TableCell>
<TableCell><CiaBadge c={s.confidentiality} i={s.integrity} a={s.availability} /></TableCell>
<TableCell><CriticalityPill level={s.serviceProfile!.criticality} label={tCrit(String(s.serviceProfile!.criticality))} /></TableCell>
<TableCell className="text-muted-foreground">{s.raci.length}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{modalService && params.edit && canWrite ? (
<ServiceEditModal service={modalService} providers={providers} />
) : modalService ? (
<ServiceDetailModal service={modalService} canWrite={canWrite} />
) : params.new && canWrite ? (
<ServiceCreateModal providers={providers} />
) : null}
</main>
);
}
/* ── Lieferanten ── */
const suppliers = await db.asset.findMany({
where: { type: "SUPPLIER", supplierProfile: { isNot: null } },
include: DETAIL_INCLUDE,
include: SUPPLIER_INCLUDE,
orderBy: { supplierProfile: { refNo: "asc" } },
take: 300,
});
@@ -96,24 +206,13 @@ export default async function SuppliersPage({
const modalId = params.edit && canWrite ? params.edit : params.detail;
const modalSupplier = modalId
? ((await db.asset.findUnique({ where: { id: modalId }, include: DETAIL_INCLUDE })) as SupplierAssetDetail | null)
? ((await db.asset.findUnique({ where: { id: modalId }, include: SUPPLIER_INCLUDE })) as SupplierAssetDetail | null)
: null;
return (
<main className="flex-1 p-6">
<PageHead
crumb={t("crumb")}
title={t("title")}
sub={t("sub")}
actions={
canWrite && (
<Button nativeButton={false} render={<Link href="/suppliers?new=1" />}>
<Plus className="size-4" /> {t("newSupplier")}
</Button>
)
}
/>
{head}
<div className="mt-3"><FilterTabs tabs={tabs} /></div>
<div className="shadow-card mt-4 rounded-xl border bg-card">
<Table>
<TableHeader>