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
+132
View File
@@ -0,0 +1,132 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { requireSession } from "@/server/auth";
import { dbForTenant, prisma } from "@/server/db";
import { requirePermission } from "@/server/rbac";
import { writeAuditLog } from "@/server/audit";
const level = z.coerce.number().int().min(1).max(4);
const serviceSchema = z.object({
name: z.string().trim().min(1).max(200),
confidentiality: level,
integrity: level,
availability: level,
criticality: level,
providerAssetId: z.string().optional(),
internal: z.string().optional(),
notes: z.string().trim().max(5000).optional(),
});
function parse(formData: FormData) {
const p = serviceSchema.parse({
name: formData.get("name"),
confidentiality: formData.get("confidentiality"),
integrity: formData.get("integrity"),
availability: formData.get("availability"),
criticality: formData.get("criticality"),
providerAssetId: formData.get("providerAssetId") || undefined,
internal: formData.get("internal") || undefined,
notes: formData.get("notes") || undefined,
});
return {
asset: { name: p.name, confidentiality: p.confidentiality, integrity: p.integrity, availability: p.availability },
profile: {
criticality: p.criticality,
providerAssetId: p.providerAssetId || null,
internal: p.internal === "on",
notes: p.notes || null,
},
};
}
export async function createService(formData: FormData) {
const session = await requireSession();
requirePermission(session, "supplier:write");
const db = dbForTenant(session.user.tenantId);
const { asset, profile } = parse(formData);
const created = await db.asset.create({
data: { ...asset, type: "IT_SERVICE", tenantId: session.user.tenantId, createdBy: session.user.id },
});
const last = await prisma.iTServiceProfile.aggregate({ where: { tenantId: session.user.tenantId }, _max: { refNo: true } });
await db.iTServiceProfile.create({
data: { ...profile, assetId: created.id, refNo: (last._max.refNo ?? 0) + 1, tenantId: session.user.tenantId, createdBy: session.user.id },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "it_service", entityId: created.id, after: { ...asset, ...profile } });
revalidatePath("/suppliers");
revalidatePath("/assets");
redirect(`/suppliers?tab=services&edit=${created.id}`);
}
export async function updateService(assetId: string, formData: FormData) {
const session = await requireSession();
requirePermission(session, "supplier:write");
const db = dbForTenant(session.user.tenantId);
const before = await db.asset.findUnique({ where: { id: assetId }, include: { serviceProfile: true } });
if (!before) throw new Error("Service nicht gefunden");
const { asset, profile } = parse(formData);
await db.asset.update({ where: { id: assetId }, data: asset });
await db.iTServiceProfile.update({ where: { assetId }, data: profile });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "it_service", entityId: assetId, before, after: { ...asset, ...profile } });
revalidatePath("/suppliers");
revalidatePath("/assets");
redirect(`/suppliers?tab=services&detail=${assetId}`);
}
export async function deleteService(assetId: string) {
const session = await requireSession();
requirePermission(session, "supplier:write");
const db = dbForTenant(session.user.tenantId);
const before = await db.asset.findUnique({ where: { id: assetId } });
if (!before) throw new Error("Service nicht gefunden");
await db.asset.delete({ where: { id: assetId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "it_service", entityId: assetId, before });
revalidatePath("/suppliers");
revalidatePath("/assets");
redirect("/suppliers?tab=services");
}
export async function addRaci(assetId: string, formData: FormData) {
const session = await requireSession();
requirePermission(session, "supplier:write");
const db = dbForTenant(session.user.tenantId);
if ((await db.asset.count({ where: { id: assetId } })) !== 1) throw new Error("Nicht gefunden");
await db.serviceControlResponsibility.create({
data: {
assetId,
tenantId: session.user.tenantId,
controlRef: z.string().trim().min(1).parse(formData.get("controlRef")),
title: (formData.get("title") as string)?.trim() || null,
applicable: formData.get("applicable") !== "off",
responsibility: z.enum(["PROVIDER", "US", "SHARED"]).parse(formData.get("responsibility") ?? "SHARED"),
evidenceRef: (formData.get("evidenceRef") as string)?.trim() || null,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "service_raci", entityId: assetId });
revalidatePath("/suppliers");
}
/** Verantwortung einer RACI-Zeile per Klick ändern (PROVIDER/US/SHARED durchschalten). */
export async function cycleRaci(assetId: string, raciId: string) {
const session = await requireSession();
requirePermission(session, "supplier:write");
const db = dbForTenant(session.user.tenantId);
const row = await db.serviceControlResponsibility.findUnique({ where: { id: raciId } });
if (!row) return;
const next = { PROVIDER: "US", US: "SHARED", SHARED: "PROVIDER" } as const;
await db.serviceControlResponsibility.update({ where: { id: raciId }, data: { responsibility: next[row.responsibility] } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "service_raci", entityId: assetId, after: { responsibility: next[row.responsibility] } });
revalidatePath("/suppliers");
}
export async function deleteRaci(assetId: string, raciId: string) {
const session = await requireSession();
requirePermission(session, "supplier:write");
const db = dbForTenant(session.user.tenantId);
await db.serviceControlResponsibility.delete({ where: { id: raciId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "service_raci", entityId: assetId });
revalidatePath("/suppliers");
}