Iteration Teil D: Lieferantenmanagement (VDA-ISA 2027 Kap. 6 + NIS2)

Datenmodell (Prisma, RLS): Supplier + SupplierAsset, SupplierAssessment,
Contract, Nda, SupplierEvidence, ServiceResponsibility, Subcontractor,
ManagementDecision, SupplierControl (globaler Katalog) +
SupplierControlMaturity.

- VDA-ISA-2027-Kap.-6-Mini-Katalog (Controls 6.1.1–6.1.3) mit Zielbild,
  Muss/Soll, Anforderungen für hoch/sehr hoch, Simplified Group
  Assessment, Ziel-Reifegrad 3 und Cross-Referenzen (ISO/NIST/BSI); im
  Seed befüllt
- Lieferantenverzeichnis mit KPIs (gesamt, NIS2-relevant, ablaufend,
  Reviews fällig), Kritikalität, NIS2-Flag, Review-Fristen
- Detail-Popup: Stammdaten (Sektor/Leistung/Datenkategorien/CIA),
  betroffene Assets, VDA-ISA-Reifegrade je Control (Ziel 3, farbige
  Balken), Nachweise (Angemessenheit + Ablauf), Assessments, Verträge
  (AV/DPA, Flow-down, Fristen), NDAs (Fristen), Shared-Responsibility-
  Matrix, Subunternehmer, Managemententscheidungen
- Bearbeiten-Popup: Stammdaten (ein Speichern), Reifegrad je Control,
  Add/Delete für alle Kind-Entitäten, Löschen im ⋯-Menü
- Regel 6.1.1: fehlt geprüfter Audit-/TISAX-Nachweis → Warnung, dass
  eine dokumentierte risikobasierte Managemententscheidung nötig ist
- Server-Actions mit Zod/RBAC/Audit-Log; Seed mit Demo-Lieferant
  (TISAX-Label, AV/DPA, NDA, Assessment, RACI, Reifegrade)
- Menüpunkt „Lieferanten" aktiv; Lieferant ↔ Asset verknüpft (Graph)

Verifiziert: Register/Detail/Bearbeiten dunkel & vollständig, Reifegrad-
Save (6.1.2→4 mit Audit), Managemententscheidungs-Logik.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Martin
2026-07-03 10:23:02 +02:00
co-authored by Claude Opus 4.8
parent 18bd82e54a
commit 32ab91efbf
12 changed files with 2110 additions and 1 deletions
+335
View File
@@ -0,0 +1,335 @@
"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 supplierSchema = z.object({
name: z.string().trim().min(1).max(200),
sector: z.string().trim().max(200).optional(),
services: z.string().trim().max(2000).optional(),
criticality: level,
dataCategories: z.string().optional(),
confidentiality: level,
integrity: level,
availability: level,
nis2Relevant: z.union([z.literal("on"), z.literal(null), z.string()]).optional(),
status: z.enum(["ACTIVE", "ONBOARDING", "UNDER_REVIEW", "OFFBOARDED"]),
contact: z.string().trim().max(200).optional(),
nextReview: z.string().optional(),
notes: z.string().trim().max(5000).optional(),
});
function parseSupplier(formData: FormData) {
const p = supplierSchema.parse({
name: formData.get("name"),
sector: formData.get("sector") || undefined,
services: formData.get("services") || undefined,
criticality: formData.get("criticality"),
dataCategories: formData.get("dataCategories") || undefined,
confidentiality: formData.get("confidentiality"),
integrity: formData.get("integrity"),
availability: formData.get("availability"),
nis2Relevant: formData.get("nis2Relevant"),
status: formData.get("status"),
contact: formData.get("contact") || undefined,
nextReview: formData.get("nextReview") || undefined,
notes: formData.get("notes") || undefined,
});
return {
name: p.name,
sector: p.sector || null,
services: p.services || null,
criticality: p.criticality,
dataCategories: p.dataCategories
? p.dataCategories.split(",").map((s) => s.trim()).filter(Boolean)
: [],
confidentiality: p.confidentiality,
integrity: p.integrity,
availability: p.availability,
nis2Relevant: p.nis2Relevant === "on",
status: p.status,
contact: p.contact || null,
nextReview: p.nextReview ? new Date(p.nextReview) : null,
notes: p.notes || null,
};
}
export async function createSupplier(formData: FormData) {
const session = await requireSession();
requirePermission(session, "supplier:write");
const db = dbForTenant(session.user.tenantId);
const data = parseSupplier(formData);
const last = await prisma.supplier.aggregate({
where: { tenantId: session.user.tenantId },
_max: { refNo: true },
});
const supplier = await db.supplier.create({
data: {
...data,
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: "supplier",
entityId: supplier.id,
after: data,
});
revalidatePath("/suppliers");
redirect(`/suppliers?edit=${supplier.id}`);
}
export async function updateSupplier(supplierId: string, formData: FormData) {
const session = await requireSession();
requirePermission(session, "supplier:write");
const db = dbForTenant(session.user.tenantId);
const before = await db.supplier.findUnique({ where: { id: supplierId } });
if (!before) throw new Error("Lieferant nicht gefunden");
const data = parseSupplier(formData);
await db.supplier.update({ where: { id: supplierId }, data });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "supplier",
entityId: supplierId,
before,
after: data,
});
revalidatePath("/suppliers");
redirect(`/suppliers?detail=${supplierId}`);
}
export async function deleteSupplier(supplierId: string) {
const session = await requireSession();
requirePermission(session, "supplier:write");
const db = dbForTenant(session.user.tenantId);
const before = await db.supplier.findUnique({ where: { id: supplierId } });
if (!before) throw new Error("Lieferant nicht gefunden");
await db.supplier.delete({ where: { id: supplierId } });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "delete",
entity: "supplier",
entityId: supplierId,
before,
});
revalidatePath("/suppliers");
redirect("/suppliers");
}
/** Generischer Helper zum Anlegen einer Kind-Entität (mit Tenant/Owner-Prüfung). */
async function ensureSupplier(session: Awaited<ReturnType<typeof requireSession>>, supplierId: string) {
const db = dbForTenant(session.user.tenantId);
const count = await db.supplier.count({ where: { id: supplierId } });
if (count !== 1) throw new Error("Lieferant nicht gefunden");
return db;
}
const optDate = (v: FormDataEntryValue | null) =>
v && String(v) ? new Date(String(v)) : null;
export async function addAssessment(supplierId: string, formData: FormData) {
const session = await requireSession();
requirePermission(session, "supplier:write");
const db = await ensureSupplier(session, supplierId);
await db.supplierAssessment.create({
data: {
supplierId,
tenantId: session.user.tenantId,
type: z.enum(["QUESTIONNAIRE", "SELF_ASSESSMENT", "AUDIT"]).parse(formData.get("type")),
status: z
.enum(["SENT", "RECEIVED", "EVALUATED", "OVERDUE"])
.parse(formData.get("status") ?? "SENT"),
score: formData.get("score") ? Number(formData.get("score")) : null,
date: optDate(formData.get("date")),
nextReview: optDate(formData.get("nextReview")),
result: (formData.get("result") as string)?.trim() || null,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "supplier_assessment", entityId: supplierId });
revalidatePath("/suppliers");
}
export async function addContract(supplierId: string, formData: FormData) {
const session = await requireSession();
requirePermission(session, "supplier:write");
const db = await ensureSupplier(session, supplierId);
const bool = (n: string) => formData.get(n) === "on";
await db.contract.create({
data: {
supplierId,
tenantId: session.user.tenantId,
type: (formData.get("type") as string) || "service",
avDpa: bool("avDpa"),
securityClauses: bool("securityClauses"),
flowdown: bool("flowdown"),
customerTransparency: bool("customerTransparency"),
validFrom: optDate(formData.get("validFrom")),
validTo: optDate(formData.get("validTo")),
reference: (formData.get("reference") as string)?.trim() || null,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "contract", entityId: supplierId });
revalidatePath("/suppliers");
}
export async function addNda(supplierId: string, formData: FormData) {
const session = await requireSession();
requirePermission(session, "supplier:write");
const db = await ensureSupplier(session, supplierId);
await db.nda.create({
data: {
supplierId,
tenantId: session.user.tenantId,
parties: (formData.get("parties") as string)?.trim() || null,
infoScope: (formData.get("infoScope") as string)?.trim() || null,
subject: (formData.get("subject") as string)?.trim() || null,
validFrom: optDate(formData.get("validFrom")),
validTo: optDate(formData.get("validTo")),
obligations: (formData.get("obligations") as string)?.trim() || null,
extensionStatus: (formData.get("extensionStatus") as string)?.trim() || null,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "nda", entityId: supplierId });
revalidatePath("/suppliers");
}
export async function addEvidence(supplierId: string, formData: FormData) {
const session = await requireSession();
requirePermission(session, "supplier:write");
const db = await ensureSupplier(session, supplierId);
await db.supplierEvidence.create({
data: {
supplierId,
tenantId: session.user.tenantId,
kind: z
.enum(["CERTIFICATE", "TISAX_LABEL", "ATTESTATION", "AUDIT_REPORT", "SELF_ASSESSMENT"])
.parse(formData.get("kind")),
name: (formData.get("name") as string)?.trim() || null,
protectsCia: (formData.get("protectsCia") as string)?.trim() || null,
validTo: optDate(formData.get("validTo")),
adequacyChecked: formData.get("adequacyChecked") === "on",
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "supplier_evidence", entityId: supplierId });
revalidatePath("/suppliers");
}
export async function addResponsibility(supplierId: string, formData: FormData) {
const session = await requireSession();
requirePermission(session, "supplier:write");
const db = await ensureSupplier(session, supplierId);
await db.serviceResponsibility.create({
data: {
supplierId,
tenantId: session.user.tenantId,
itService: z.string().trim().min(1).parse(formData.get("itService")),
requirement: z.string().trim().min(1).parse(formData.get("requirement")),
responsibleParty: z
.enum(["CLIENT", "SUPPLIER", "SHARED"])
.parse(formData.get("responsibleParty") ?? "SHARED"),
isaApplicability: (formData.get("isaApplicability") as string)?.trim() || null,
evidence: (formData.get("evidence") as string)?.trim() || null,
integratedLocalControls: (formData.get("integratedLocalControls") as string)?.trim() || null,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "service_responsibility", entityId: supplierId });
revalidatePath("/suppliers");
}
export async function addSubcontractor(supplierId: string, formData: FormData) {
const session = await requireSession();
requirePermission(session, "supplier:write");
const db = await ensureSupplier(session, supplierId);
await db.subcontractor.create({
data: {
supplierId,
tenantId: session.user.tenantId,
name: z.string().trim().min(1).parse(formData.get("name")),
flowdownObligation: formData.get("flowdownObligation") === "on",
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "subcontractor", entityId: supplierId });
revalidatePath("/suppliers");
}
export async function addDecision(supplierId: string, formData: FormData) {
const session = await requireSession();
requirePermission(session, "supplier:write");
const db = await ensureSupplier(session, supplierId);
await db.managementDecision.create({
data: {
supplierId,
tenantId: session.user.tenantId,
reasonNoAudit: z.string().trim().min(1).parse(formData.get("reasonNoAudit")),
decision: z.string().trim().min(1).parse(formData.get("decision")),
decidedBy: (formData.get("decidedBy") as string)?.trim() || session.user.name || null,
recordRef: (formData.get("recordRef") as string)?.trim() || null,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "management_decision", entityId: supplierId });
revalidatePath("/suppliers");
}
export async function saveMaturity(supplierId: string, controlId: string, formData: FormData) {
await setControlMaturity(supplierId, controlId, Number(formData.get("maturity")));
}
export async function setControlMaturity(supplierId: string, controlId: string, maturity: number) {
const session = await requireSession();
requirePermission(session, "supplier:write");
const db = await ensureSupplier(session, supplierId);
const m = z.coerce.number().int().min(0).max(5).parse(maturity);
await db.supplierControlMaturity.upsert({
where: { supplierId_controlId: { supplierId, controlId } },
update: { maturity: m },
create: { supplierId, controlId, maturity: m, tenantId: session.user.tenantId },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "supplier_control_maturity", entityId: supplierId, after: { controlId, maturity: m } });
revalidatePath("/suppliers");
}
/** Generisches Löschen einer Kind-Entität. */
export async function deleteChild(
kind:
| "assessment"
| "contract"
| "nda"
| "evidence"
| "responsibility"
| "subcontractor"
| "decision",
id: string
) {
const session = await requireSession();
requirePermission(session, "supplier:write");
const db = dbForTenant(session.user.tenantId);
const map = {
assessment: db.supplierAssessment,
contract: db.contract,
nda: db.nda,
evidence: db.supplierEvidence,
responsibility: db.serviceResponsibility,
subcontractor: db.subcontractor,
decision: db.managementDecision,
} as const;
// @ts-expect-error dynamischer Delegate-Zugriff
await map[kind].delete({ where: { id } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: `supplier_${kind}`, entityId: id });
revalidatePath("/suppliers");
}
+10
View File
@@ -37,6 +37,16 @@ const TENANT_MODELS = new Set<string>([
"RiskAsset",
"Measure",
"RiskMeasure",
"Supplier",
"SupplierAsset",
"SupplierAssessment",
"Contract",
"Nda",
"SupplierEvidence",
"ServiceResponsibility",
"Subcontractor",
"ManagementDecision",
"SupplierControlMaturity",
]);
/**