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