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
+117
View File
@@ -341,5 +341,122 @@
"legCrit": "Kritisch (K≥3)",
"empty": "Noch keine Prozesse/Assets für einen Graphen vorhanden.",
"openGraph": "Im Abhängigkeitsgraph anzeigen"
},
"suppliers": {
"title": "Lieferanten & Dienstleister",
"sub": "VDA-ISA 2027 Kap. 6 · NIS2 Lieferkette (Art. 21(2)(d))",
"crumb": "Fachdaten",
"new": "Lieferant",
"newSupplier": "Neuer Lieferant",
"ref": "ID",
"kpiTotal": "Lieferanten",
"kpiNis2": "NIS2-relevant",
"kpiExpiring": "Ablaufend (90 T.)",
"kpiReviews": "Reviews fällig",
"name": "Name",
"sector": "Sektor",
"services": "Leistung / IT-Services",
"criticality": "Kritikalität",
"dataCategories": "Datenkategorien (kommagetrennt)",
"protection": "Schutzbedarf",
"nis2": "NIS2-relevant (Lieferkette)",
"status": "Status",
"contact": "Kontakt",
"nextReview": "Nächstes Review",
"notes": "Anmerkungen",
"empty": "Keine Lieferanten erfasst.",
"detailSub": "Bewertung, Verträge, Nachweise & Verantwortung",
"createTitle": "Lieferant anlegen",
"editTitle": "Lieferant bearbeiten",
"masterPill": "■ Stammdaten",
"catalog": "VDA-ISA 2027 · Kap. 6 Supplier Relationships",
"catalogNote": "Reifegrad je Prüfziel (Ziel 3)",
"target": "Ziel",
"maturity": "Reifegrad",
"references": "Referenzen",
"objective": "Zielbild",
"must": "Muss",
"should": "Soll",
"high": "Hoher Schutzbedarf",
"veryHigh": "Sehr hoher Schutzbedarf",
"sga": "Simplified Group Assessment",
"assessments": "Sicherheitsbewertungen",
"addAssessment": "Bewertung hinzufügen",
"score": "Score",
"result": "Ergebnis",
"type": "Typ",
"date": "Datum",
"contracts": "Verträge",
"addContract": "Vertrag hinzufügen",
"avDpa": "AV/DPA (Art. 28)",
"securityClauses": "Sicherheitsklauseln",
"flowdown": "Flow-down (Subunternehmer)",
"customerTransparency": "Kunden-Transparenz",
"validFrom": "Gültig ab",
"validTo": "Gültig bis",
"reference": "Referenz",
"ndas": "NDA / Geheimhaltung",
"addNda": "NDA hinzufügen",
"parties": "Parteien",
"infoScope": "Informationsart",
"subject": "Gegenstand",
"obligations": "Pflichten",
"extensionStatus": "Verlängerung",
"evidence": "Nachweise & Assurance",
"addEvidence": "Nachweis hinzufügen",
"kind": "Art",
"protectsCia": "Deckt (C/I/A)",
"adequacy": "Angemessenheit geprüft",
"expires": "läuft ab",
"raci": "Verantwortung (Shared Responsibility)",
"addRaci": "Zuordnung hinzufügen",
"itService": "IT-Service",
"requirement": "Anforderung",
"responsible": "Verantwortlich",
"isaApplicability": "ISA-Anwendbarkeit",
"localControls": "Lokale Schutzmaßnahmen",
"subcontractors": "Subunternehmer (4th Party)",
"addSub": "Subunternehmer hinzufügen",
"flowdownObl": "Flow-down-Pflicht",
"decision": "Risikobasierte Managemententscheidung",
"addDecision": "Entscheidung protokollieren",
"reasonNoAudit": "Grund (kein Audit/Label)",
"decisionText": "Entscheidung",
"decidedBy": "Entschieden von",
"recordRef": "Aktenzeichen",
"decisionNeeded": "Kein Third-Party-Audit/TISAX-Label mit geprüfter Angemessenheit vorhanden — eine dokumentierte risikobasierte Managemententscheidung ist erforderlich.",
"assets": "Betroffene Assets",
"close": "Schließen",
"none": "—",
"add": "Hinzufügen"
},
"assessmentType": {
"QUESTIONNAIRE": "Fragebogen",
"SELF_ASSESSMENT": "Self-Assessment",
"AUDIT": "Audit"
},
"assessmentStatus": {
"SENT": "Versendet",
"RECEIVED": "Eingegangen",
"EVALUATED": "Bewertet",
"OVERDUE": "Überfällig"
},
"evidenceKind": {
"CERTIFICATE": "Zertifikat",
"TISAX_LABEL": "TISAX-Label",
"ATTESTATION": "Attestierung",
"AUDIT_REPORT": "Auditbericht",
"SELF_ASSESSMENT": "Self-Assessment"
},
"responsibleParty": {
"CLIENT": "Kunde",
"SUPPLIER": "Lieferant",
"SHARED": "Geteilt"
},
"supplierStatus": {
"ACTIVE": "Aktiv",
"ONBOARDING": "Onboarding",
"UNDER_REVIEW": "In Prüfung",
"OFFBOARDED": "Beendet"
}
}
+117
View File
@@ -341,5 +341,122 @@
"legCrit": "Critical (K≥3)",
"empty": "No processes/assets available for a graph yet.",
"openGraph": "Show in dependency graph"
},
"suppliers": {
"title": "Suppliers & service providers",
"sub": "VDA-ISA 2027 ch. 6 · NIS2 supply chain (Art. 21(2)(d))",
"crumb": "Core data",
"new": "Supplier",
"newSupplier": "New supplier",
"ref": "ID",
"kpiTotal": "Suppliers",
"kpiNis2": "NIS2-relevant",
"kpiExpiring": "Expiring (90 d)",
"kpiReviews": "Reviews due",
"name": "Name",
"sector": "Sector",
"services": "Service / IT services",
"criticality": "Criticality",
"dataCategories": "Data categories (comma-separated)",
"protection": "Protection needs",
"nis2": "NIS2-relevant (supply chain)",
"status": "Status",
"contact": "Contact",
"nextReview": "Next review",
"notes": "Notes",
"empty": "No suppliers recorded.",
"detailSub": "Assessment, contracts, evidence & responsibility",
"createTitle": "Create supplier",
"editTitle": "Edit supplier",
"masterPill": "■ Master data",
"catalog": "VDA-ISA 2027 · ch. 6 Supplier Relationships",
"catalogNote": "Maturity per objective (target 3)",
"target": "Target",
"maturity": "Maturity",
"references": "References",
"objective": "Objective",
"must": "Must",
"should": "Should",
"high": "High protection",
"veryHigh": "Very high protection",
"sga": "Simplified Group Assessment",
"assessments": "Security assessments",
"addAssessment": "Add assessment",
"score": "Score",
"result": "Result",
"type": "Type",
"date": "Date",
"contracts": "Contracts",
"addContract": "Add contract",
"avDpa": "DPA (Art. 28)",
"securityClauses": "Security clauses",
"flowdown": "Flow-down (subcontractors)",
"customerTransparency": "Customer transparency",
"validFrom": "Valid from",
"validTo": "Valid to",
"reference": "Reference",
"ndas": "NDA / non-disclosure",
"addNda": "Add NDA",
"parties": "Parties",
"infoScope": "Information type",
"subject": "Subject",
"obligations": "Obligations",
"extensionStatus": "Extension",
"evidence": "Evidence & assurance",
"addEvidence": "Add evidence",
"kind": "Kind",
"protectsCia": "Covers (C/I/A)",
"adequacy": "Adequacy checked",
"expires": "expires",
"raci": "Responsibility (shared responsibility)",
"addRaci": "Add assignment",
"itService": "IT service",
"requirement": "Requirement",
"responsible": "Responsible",
"isaApplicability": "ISA applicability",
"localControls": "Local controls",
"subcontractors": "Subcontractors (4th party)",
"addSub": "Add subcontractor",
"flowdownObl": "Flow-down obligation",
"decision": "Risk-based management decision",
"addDecision": "Record decision",
"reasonNoAudit": "Reason (no audit/label)",
"decisionText": "Decision",
"decidedBy": "Decided by",
"recordRef": "Record ref",
"decisionNeeded": "No third-party audit/TISAX label with checked adequacy present — a documented risk-based management decision is required.",
"assets": "Affected assets",
"close": "Close",
"none": "—",
"add": "Add"
},
"assessmentType": {
"QUESTIONNAIRE": "Questionnaire",
"SELF_ASSESSMENT": "Self-assessment",
"AUDIT": "Audit"
},
"assessmentStatus": {
"SENT": "Sent",
"RECEIVED": "Received",
"EVALUATED": "Evaluated",
"OVERDUE": "Overdue"
},
"evidenceKind": {
"CERTIFICATE": "Certificate",
"TISAX_LABEL": "TISAX label",
"ATTESTATION": "Attestation",
"AUDIT_REPORT": "Audit report",
"SELF_ASSESSMENT": "Self-assessment"
},
"responsibleParty": {
"CLIENT": "Client",
"SUPPLIER": "Supplier",
"SHARED": "Shared"
},
"supplierStatus": {
"ACTIVE": "Active",
"ONBOARDING": "Onboarding",
"UNDER_REVIEW": "Under review",
"OFFBOARDED": "Offboarded"
}
}
@@ -0,0 +1,260 @@
-- CreateEnum
CREATE TYPE "SupplierStatus" AS ENUM ('ACTIVE', 'ONBOARDING', 'UNDER_REVIEW', 'OFFBOARDED');
-- CreateEnum
CREATE TYPE "AssessmentType" AS ENUM ('QUESTIONNAIRE', 'SELF_ASSESSMENT', 'AUDIT');
-- CreateEnum
CREATE TYPE "AssessmentStatus" AS ENUM ('SENT', 'RECEIVED', 'EVALUATED', 'OVERDUE');
-- CreateEnum
CREATE TYPE "EvidenceKind" AS ENUM ('CERTIFICATE', 'TISAX_LABEL', 'ATTESTATION', 'AUDIT_REPORT', 'SELF_ASSESSMENT');
-- CreateEnum
CREATE TYPE "ResponsibleParty" AS ENUM ('CLIENT', 'SUPPLIER', 'SHARED');
-- CreateTable
CREATE TABLE "suppliers" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"ref_no" INTEGER NOT NULL,
"name" TEXT NOT NULL,
"sector" TEXT,
"services" TEXT,
"criticality" INTEGER NOT NULL DEFAULT 1,
"data_categories" TEXT[] DEFAULT ARRAY[]::TEXT[],
"confidentiality" INTEGER NOT NULL DEFAULT 1,
"integrity" INTEGER NOT NULL DEFAULT 1,
"availability" INTEGER NOT NULL DEFAULT 1,
"nis2_relevant" BOOLEAN NOT NULL DEFAULT false,
"status" "SupplierStatus" NOT NULL DEFAULT 'ACTIVE',
"contact" TEXT,
"next_review" TIMESTAMP(3),
"notes" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"created_by" TEXT,
CONSTRAINT "suppliers_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "supplier_assets" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"supplier_id" TEXT NOT NULL,
"asset_id" TEXT NOT NULL,
CONSTRAINT "supplier_assets_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "supplier_assessments" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"supplier_id" TEXT NOT NULL,
"type" "AssessmentType" NOT NULL,
"status" "AssessmentStatus" NOT NULL DEFAULT 'SENT',
"score" INTEGER,
"date" TIMESTAMP(3),
"next_review" TIMESTAMP(3),
"result" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "supplier_assessments_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "contracts" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"supplier_id" TEXT NOT NULL,
"type" TEXT NOT NULL DEFAULT 'service',
"av_dpa" BOOLEAN NOT NULL DEFAULT false,
"security_clauses" BOOLEAN NOT NULL DEFAULT false,
"flowdown" BOOLEAN NOT NULL DEFAULT false,
"customer_transparency" BOOLEAN NOT NULL DEFAULT false,
"valid_from" TIMESTAMP(3),
"valid_to" TIMESTAMP(3),
"reference" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "contracts_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ndas" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"supplier_id" TEXT NOT NULL,
"parties" TEXT,
"info_scope" TEXT,
"subject" TEXT,
"valid_from" TIMESTAMP(3),
"valid_to" TIMESTAMP(3),
"obligations" TEXT,
"extension_status" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ndas_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "supplier_evidence" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"supplier_id" TEXT NOT NULL,
"kind" "EvidenceKind" NOT NULL,
"name" TEXT,
"protects_cia" TEXT,
"valid_to" TIMESTAMP(3),
"adequacy_checked" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "supplier_evidence_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "service_responsibilities" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"supplier_id" TEXT NOT NULL,
"it_service" TEXT NOT NULL,
"requirement" TEXT NOT NULL,
"responsible_party" "ResponsibleParty" NOT NULL DEFAULT 'SHARED',
"isa_applicability" TEXT,
"evidence" TEXT,
"integrated_local_controls" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "service_responsibilities_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "subcontractors" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"supplier_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"flowdown_obligation" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "subcontractors_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "management_decisions" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"supplier_id" TEXT NOT NULL,
"reason_no_audit" TEXT NOT NULL,
"decision" TEXT NOT NULL,
"decided_by" TEXT,
"date" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"record_ref" TEXT,
CONSTRAINT "management_decisions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "supplier_controls" (
"id" TEXT NOT NULL,
"ref" TEXT NOT NULL,
"title" TEXT NOT NULL,
"objective" TEXT NOT NULL,
"must_req" TEXT,
"should_req" TEXT,
"high_req" TEXT,
"very_high_req" TEXT,
"simplified_group_assessment" BOOLEAN NOT NULL DEFAULT false,
"target_maturity" INTEGER NOT NULL DEFAULT 3,
"references" TEXT,
CONSTRAINT "supplier_controls_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "supplier_control_maturity" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"supplier_id" TEXT NOT NULL,
"control_id" TEXT NOT NULL,
"maturity" INTEGER NOT NULL DEFAULT 0,
"notes" TEXT,
CONSTRAINT "supplier_control_maturity_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "suppliers_tenant_id_idx" ON "suppliers"("tenant_id");
-- CreateIndex
CREATE UNIQUE INDEX "suppliers_tenant_id_ref_no_key" ON "suppliers"("tenant_id", "ref_no");
-- CreateIndex
CREATE INDEX "supplier_assets_tenant_id_idx" ON "supplier_assets"("tenant_id");
-- CreateIndex
CREATE UNIQUE INDEX "supplier_assets_supplier_id_asset_id_key" ON "supplier_assets"("supplier_id", "asset_id");
-- CreateIndex
CREATE INDEX "supplier_assessments_tenant_id_idx" ON "supplier_assessments"("tenant_id");
-- CreateIndex
CREATE INDEX "contracts_tenant_id_idx" ON "contracts"("tenant_id");
-- CreateIndex
CREATE INDEX "ndas_tenant_id_idx" ON "ndas"("tenant_id");
-- CreateIndex
CREATE INDEX "supplier_evidence_tenant_id_idx" ON "supplier_evidence"("tenant_id");
-- CreateIndex
CREATE INDEX "service_responsibilities_tenant_id_idx" ON "service_responsibilities"("tenant_id");
-- CreateIndex
CREATE INDEX "subcontractors_tenant_id_idx" ON "subcontractors"("tenant_id");
-- CreateIndex
CREATE INDEX "management_decisions_tenant_id_idx" ON "management_decisions"("tenant_id");
-- CreateIndex
CREATE UNIQUE INDEX "supplier_controls_ref_key" ON "supplier_controls"("ref");
-- CreateIndex
CREATE INDEX "supplier_control_maturity_tenant_id_idx" ON "supplier_control_maturity"("tenant_id");
-- CreateIndex
CREATE UNIQUE INDEX "supplier_control_maturity_supplier_id_control_id_key" ON "supplier_control_maturity"("supplier_id", "control_id");
-- AddForeignKey
ALTER TABLE "supplier_assets" ADD CONSTRAINT "supplier_assets_supplier_id_fkey" FOREIGN KEY ("supplier_id") REFERENCES "suppliers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "supplier_assets" ADD CONSTRAINT "supplier_assets_asset_id_fkey" FOREIGN KEY ("asset_id") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "supplier_assessments" ADD CONSTRAINT "supplier_assessments_supplier_id_fkey" FOREIGN KEY ("supplier_id") REFERENCES "suppliers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "contracts" ADD CONSTRAINT "contracts_supplier_id_fkey" FOREIGN KEY ("supplier_id") REFERENCES "suppliers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ndas" ADD CONSTRAINT "ndas_supplier_id_fkey" FOREIGN KEY ("supplier_id") REFERENCES "suppliers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "supplier_evidence" ADD CONSTRAINT "supplier_evidence_supplier_id_fkey" FOREIGN KEY ("supplier_id") REFERENCES "suppliers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "service_responsibilities" ADD CONSTRAINT "service_responsibilities_supplier_id_fkey" FOREIGN KEY ("supplier_id") REFERENCES "suppliers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "subcontractors" ADD CONSTRAINT "subcontractors_supplier_id_fkey" FOREIGN KEY ("supplier_id") REFERENCES "suppliers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "management_decisions" ADD CONSTRAINT "management_decisions_supplier_id_fkey" FOREIGN KEY ("supplier_id") REFERENCES "suppliers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "supplier_control_maturity" ADD CONSTRAINT "supplier_control_maturity_supplier_id_fkey" FOREIGN KEY ("supplier_id") REFERENCES "suppliers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "supplier_control_maturity" ADD CONSTRAINT "supplier_control_maturity_control_id_fkey" FOREIGN KEY ("control_id") REFERENCES "supplier_controls"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,16 @@
-- RLS-Policies für die Lieferanten-Tabellen (analog zu row_level_security)
DO $$
DECLARE t text;
BEGIN
FOREACH t IN ARRAY ARRAY[
'suppliers','supplier_assets','supplier_assessments','contracts','ndas',
'supplier_evidence','service_responsibilities','subcontractors',
'management_decisions','supplier_control_maturity'
] LOOP
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', t);
EXECUTE format(
'CREATE POLICY tenant_isolation ON %I USING (tenant_id = current_setting(''app.tenant_id'', true))',
t
);
END LOOP;
END $$;
+248
View File
@@ -166,6 +166,7 @@ model Asset {
relationsTo AssetRelation[] @relation("relationTo")
processAssets ProcessAsset[]
riskAssets RiskAsset[]
supplierLinks SupplierAsset[]
@@index([tenantId])
@@index([tenantId, type])
@@ -396,6 +397,253 @@ model Vulnerability {
@@map("vulnerabilities")
}
// ── Lieferantenmanagement (SPEC §4.14, VDA-ISA 2027 Kap. 6, NIS2 Art. 21(2)(d)) ──
enum SupplierStatus {
ACTIVE
ONBOARDING
UNDER_REVIEW
OFFBOARDED
}
enum AssessmentType {
QUESTIONNAIRE
SELF_ASSESSMENT
AUDIT
}
enum AssessmentStatus {
SENT
RECEIVED
EVALUATED
OVERDUE
}
enum EvidenceKind {
CERTIFICATE
TISAX_LABEL
ATTESTATION
AUDIT_REPORT
SELF_ASSESSMENT
}
enum ResponsibleParty {
CLIENT
SUPPLIER
SHARED
}
model Supplier {
id String @id @default(cuid())
tenantId String @map("tenant_id")
refNo Int @map("ref_no") // Anzeige "L-001"
name String
sector String?
services String? // erbrachte Leistung / IT-Services
criticality Int @default(1) // 14
dataCategories String[] @default([]) @map("data_categories")
// Schutzbedarf der verarbeiteten Informationen (C/I/A 14)
confidentiality Int @default(1)
integrity Int @default(1)
availability Int @default(1)
nis2Relevant Boolean @default(false) @map("nis2_relevant") // Teil der Lieferkette
status SupplierStatus @default(ACTIVE)
contact String?
nextReview DateTime? @map("next_review")
notes String?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
createdBy String? @map("created_by")
assessments SupplierAssessment[]
contracts Contract[]
ndas Nda[]
evidence SupplierEvidence[]
responsibilities ServiceResponsibility[]
subcontractors Subcontractor[]
decisions ManagementDecision[]
controlMaturity SupplierControlMaturity[]
assetLinks SupplierAsset[]
@@unique([tenantId, refNo])
@@index([tenantId])
@@map("suppliers")
}
// Lieferant ↔ Asset (welcher Dienstleister betrifft welche Assets) — speist den Graph
model SupplierAsset {
id String @id @default(cuid())
tenantId String @map("tenant_id")
supplierId String @map("supplier_id")
assetId String @map("asset_id")
supplier Supplier @relation(fields: [supplierId], references: [id], onDelete: Cascade)
asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade)
@@unique([supplierId, assetId])
@@index([tenantId])
@@map("supplier_assets")
}
model SupplierAssessment {
id String @id @default(cuid())
tenantId String @map("tenant_id")
supplierId String @map("supplier_id")
type AssessmentType
status AssessmentStatus @default(SENT)
score Int? // 0100 Scoring
date DateTime?
nextReview DateTime? @map("next_review")
result String?
createdAt DateTime @default(now()) @map("created_at")
supplier Supplier @relation(fields: [supplierId], references: [id], onDelete: Cascade)
@@index([tenantId])
@@map("supplier_assessments")
}
model Contract {
id String @id @default(cuid())
tenantId String @map("tenant_id")
supplierId String @map("supplier_id")
type String @default("service") // service | av_dpa | nda | sla …
avDpa Boolean @default(false) @map("av_dpa") // AV/DPA (DSGVO Art. 28)
securityClauses Boolean @default(false) @map("security_clauses")
flowdown Boolean @default(false) // Weitergabe an Subunternehmer
customerTransparency Boolean @default(false) @map("customer_transparency")
validFrom DateTime? @map("valid_from")
validTo DateTime? @map("valid_to")
reference String?
createdAt DateTime @default(now()) @map("created_at")
supplier Supplier @relation(fields: [supplierId], references: [id], onDelete: Cascade)
@@index([tenantId])
@@map("contracts")
}
model Nda {
id String @id @default(cuid())
tenantId String @map("tenant_id")
supplierId String @map("supplier_id")
parties String?
infoScope String? @map("info_scope")
subject String?
validFrom DateTime? @map("valid_from")
validTo DateTime? @map("valid_to")
obligations String?
extensionStatus String? @map("extension_status") // z. B. offen | verlängert | ausgelaufen
createdAt DateTime @default(now()) @map("created_at")
supplier Supplier @relation(fields: [supplierId], references: [id], onDelete: Cascade)
@@index([tenantId])
@@map("ndas")
}
model SupplierEvidence {
id String @id @default(cuid())
tenantId String @map("tenant_id")
supplierId String @map("supplier_id")
kind EvidenceKind
name String?
protectsCia String? @map("protects_cia") // z. B. "C,I,A"
validTo DateTime? @map("valid_to")
adequacyChecked Boolean @default(false) @map("adequacy_checked")
createdAt DateTime @default(now()) @map("created_at")
supplier Supplier @relation(fields: [supplierId], references: [id], onDelete: Cascade)
@@index([tenantId])
@@map("supplier_evidence")
}
model ServiceResponsibility {
id String @id @default(cuid())
tenantId String @map("tenant_id")
supplierId String @map("supplier_id")
itService String @map("it_service")
requirement String
responsibleParty ResponsibleParty @default(SHARED) @map("responsible_party")
isaApplicability String? @map("isa_applicability")
evidence String?
integratedLocalControls String? @map("integrated_local_controls")
createdAt DateTime @default(now()) @map("created_at")
supplier Supplier @relation(fields: [supplierId], references: [id], onDelete: Cascade)
@@index([tenantId])
@@map("service_responsibilities")
}
model Subcontractor {
id String @id @default(cuid())
tenantId String @map("tenant_id")
supplierId String @map("supplier_id")
name String
flowdownObligation Boolean @default(false) @map("flowdown_obligation")
supplier Supplier @relation(fields: [supplierId], references: [id], onDelete: Cascade)
@@index([tenantId])
@@map("subcontractors")
}
model ManagementDecision {
id String @id @default(cuid())
tenantId String @map("tenant_id")
supplierId String @map("supplier_id")
reasonNoAudit String @map("reason_no_audit")
decision String
decidedBy String? @map("decided_by")
date DateTime @default(now())
recordRef String? @map("record_ref")
supplier Supplier @relation(fields: [supplierId], references: [id], onDelete: Cascade)
@@index([tenantId])
@@map("management_decisions")
}
// VDA-ISA 2027 Kapitel 6 — Supplier Relationships (globaler Katalog, per Import versioniert)
model SupplierControl {
id String @id @default(cuid())
ref String @unique // 6.1.1 / 6.1.2 / 6.1.3
title String
objective String
mustReq String? @map("must_req")
shouldReq String? @map("should_req")
highReq String? @map("high_req") // Additional for high protection
veryHighReq String? @map("very_high_req") // Additional for very high protection
simplifiedGroupAssessment Boolean @default(false) @map("simplified_group_assessment")
targetMaturity Int @default(3) @map("target_maturity")
references String? // ISO 27001, NIST CSF, BSI …
maturity SupplierControlMaturity[]
@@map("supplier_controls")
}
model SupplierControlMaturity {
id String @id @default(cuid())
tenantId String @map("tenant_id")
supplierId String @map("supplier_id")
controlId String @map("control_id")
maturity Int @default(0) // 05
notes String?
supplier Supplier @relation(fields: [supplierId], references: [id], onDelete: Cascade)
control SupplierControl @relation(fields: [controlId], references: [id], onDelete: Cascade)
@@unique([supplierId, controlId])
@@index([tenantId])
@@map("supplier_control_maturity")
}
model AuditLog {
id String @id @default(cuid())
tenantId String @map("tenant_id")
+164
View File
@@ -452,6 +452,170 @@ async function main() {
}
console.log(`${measures.length} Beispiel-Maßnahmen angelegt, Rest-Risiken berechnet`);
}
// 8. VDA-ISA 2027 Kapitel 6 — Supplier Relationships (globaler Katalog)
const controls = [
{
ref: "6.1.1",
title:
"To what extent is information security ensured among contractors and cooperation partners?",
objective:
"Ein angemessenes Informationssicherheitsniveau wird auch bei der Zusammenarbeit mit Partnern und Auftragnehmern aufrechterhalten.",
mustReq:
"Auftragnehmer/Partner werden einer Sicherheits­risikobewertung unterzogen; angemessenes Niveau vertraglich sichergestellt; Kundenanforderungen ggf. weitergegeben.",
shouldReq:
"Vertragliche Verpflichtung zur Weitergabe an Subunternehmer; Prüfung von Service-Reports/Dokumenten.",
highReq:
"Nachweis eines angemessenen Sicherheitsniveaus (geprüfter Fragebogen/Self-Assessment, Attest, Zertifikat, Lieferantenaudit) (C,I,A); Compliance dokumentiert, regelmäßig & anlassbezogen überwacht.",
veryHighReq:
"Nachweis durch Third-Party-Audit (adäquates TISAX-Label o. Ä.) oder Lieferantenaudit; ohne Audit: risikobasierte Management­entscheidung mit Protokoll (C,I,A); Transparenzpflichten gegenüber Kunden erfüllt.",
targetMaturity: 3,
simplifiedGroupAssessment: true,
references:
"ISO 27001:2022 A.5.19A.5.22 · NIST CSF 2.0 GV.SC-02..06 · BSI OPS.2.1/OPS.2.2/OPS.3.1/ORP.2 · NIST SP800-53r5 MA-4, CA-3, CA-6, PM-16, PM-30, SR-2, SR-3, SR-6, SR-7, SR-8",
},
{
ref: "6.1.2",
title:
"To what extent is non-disclosure regarding the exchange of information contractually agreed?",
objective:
"Geheimhaltungsvereinbarungen schützen den Informationsaustausch über Organisationsgrenzen hinweg rechtlich.",
mustReq:
"Geheimhaltungs­anforderungen bestimmt und erfüllt; allen Beteiligten bekannt; gültige NDAs vor Weitergabe sensibler Infos; regelmäßige Überprüfung.",
shouldReq:
"Geprüfte NDA-Vorlagen; NDAs mit Parteien, Informationsart, Gegenstand, Gültigkeit, Pflichten; Nachweis-/Auditrechte; Prozess zur Fristenüberwachung und rechtzeitigen Verlängerung.",
highReq: null,
veryHighReq: null,
targetMaturity: 3,
simplifiedGroupAssessment: true,
references: "ISO 27001:2022 A.5.14, A.6.6 · BSI OPS.2.1/OPS.2.2/OPS.3.1/ORP.5/CON.2",
},
{
ref: "6.1.3",
title:
"To what extent are the responsibilities between external IT service providers and the own organization defined?",
objective:
"Gemeinsames Verständnis der Verantwortungsteilung; alle Sicherheitsanforderungen umgesetzt und nachweislich dokumentiert.",
mustReq:
"Betroffene IT-Services identifiziert; Anforderungen bestimmt; verantwortliche Organisation je Anforderung definiert; Mechanismen für geteilte Verantwortung umgesetzt.",
shouldReq:
"Konfiguration konzeptioniert/umgesetzt/dokumentiert; zuständiges Personal geschult.",
highReq:
"Liste der IT-Services und Provider (C,I,A); ISA-Control-Anwendbarkeit bewertet; regelmäßige Security-Assessments; Nachweis der Pflichterfüllung; Integration in lokale Schutzmaßnahmen dokumentiert.",
veryHighReq: null,
targetMaturity: 3,
simplifiedGroupAssessment: false,
references:
"ISO 27001:2022 A.5.23, A.8.9 · ISO 27017 CLD.6.3.1 · IEC 62443-2-1 6.2.3 · NIST CSF 2.0 GV.OC-05, GV.SC-01/02 · BSI 200-2, OPS.2.1/OPS.2.2/OPS.3.1/ORP.2 · NIST SP800-53r5 MA-4, PT-1, PL-2",
},
];
for (const c of controls) {
await prisma.supplierControl.upsert({ where: { ref: c.ref }, update: c, create: c });
}
console.log(`✔ VDA-ISA 2027 Kap. 6: ${controls.length} Supplier-Controls`);
// 9. Demo-Lieferant mit Nachweisen/Verträgen (nur wenn noch keiner existiert)
const supplierCount = await prisma.supplier.count({ where: { tenantId: tenant.id } });
if (supplierCount === 0) {
const hoster = await prisma.asset.findFirst({
where: { tenantId: tenant.id, name: "Cloud-Hoster (IaaS)" },
});
const sup = await prisma.supplier.create({
data: {
tenantId: tenant.id,
refNo: 1,
name: "Cloud-Hoster GmbH",
sector: "IT-Dienstleistung / IaaS",
services: "Rechenzentrum, Virtualisierung, Backup für ERP & CRM",
criticality: 4,
dataCategories: ["Kundendaten", "Auftragsdaten"],
confidentiality: 3,
integrity: 3,
availability: 4,
nis2Relevant: true,
status: "ACTIVE",
contact: "security@cloud-hoster.example",
nextReview: new Date(Date.now() + 90 * 24 * 3600 * 1000),
},
});
if (hoster) {
await prisma.supplierAsset.create({
data: { tenantId: tenant.id, supplierId: sup.id, assetId: hoster.id },
});
}
await prisma.supplierEvidence.create({
data: {
tenantId: tenant.id,
supplierId: sup.id,
kind: "TISAX_LABEL",
name: "TISAX AL3 (info high)",
protectsCia: "C,I,A",
validTo: new Date(Date.now() + 200 * 24 * 3600 * 1000),
adequacyChecked: true,
},
});
await prisma.contract.create({
data: {
tenantId: tenant.id,
supplierId: sup.id,
type: "av_dpa",
avDpa: true,
securityClauses: true,
flowdown: true,
customerTransparency: false,
validFrom: new Date(Date.now() - 300 * 24 * 3600 * 1000),
validTo: new Date(Date.now() + 400 * 24 * 3600 * 1000),
reference: "AV-2025-014",
},
});
await prisma.nda.create({
data: {
tenantId: tenant.id,
supplierId: sup.id,
parties: "Demo GmbH ↔ Cloud-Hoster GmbH",
infoScope: "Betriebs- und Kundendaten",
subject: "Betrieb der ERP-/CRM-Infrastruktur",
validFrom: new Date(Date.now() - 300 * 24 * 3600 * 1000),
validTo: new Date(Date.now() + 60 * 24 * 3600 * 1000),
extensionStatus: "offen",
},
});
await prisma.supplierAssessment.create({
data: {
tenantId: tenant.id,
supplierId: sup.id,
type: "SELF_ASSESSMENT",
status: "EVALUATED",
score: 82,
date: new Date(Date.now() - 120 * 24 * 3600 * 1000),
nextReview: new Date(Date.now() + 245 * 24 * 3600 * 1000),
result: "angemessen",
},
});
await prisma.serviceResponsibility.create({
data: {
tenantId: tenant.id,
supplierId: sup.id,
itService: "IaaS-Plattform",
requirement: "Patch-Management der Hypervisor-Ebene",
responsibleParty: "SUPPLIER",
isaApplicability: "5.x IT/Cyber Security",
integratedLocalControls: "Sichere Authentisierung, Monitoring beim Kunden",
},
});
const dbControls = await prisma.supplierControl.findMany();
for (const c of dbControls) {
await prisma.supplierControlMaturity.create({
data: {
tenantId: tenant.id,
supplierId: sup.id,
controlId: c.id,
maturity: c.ref === "6.1.2" ? 2 : 3,
},
});
}
console.log("✔ Demo-Lieferant mit Nachweisen/Vertrag/NDA/Assessment angelegt");
}
}
main()
+1 -1
View File
@@ -43,7 +43,7 @@ export default async function AppLayout({
{ href: "/chat", label: t("chat"), icon: MessagesSquare, enabled: false },
{ href: "/dependencies", label: t("dependencies"), icon: Network, enabled: true },
{ href: "/evidence", label: t("evidence"), icon: FolderCheck, enabled: false },
{ href: "/suppliers", label: t("suppliers"), icon: Truck, enabled: false },
{ href: "/suppliers", label: t("suppliers"), icon: Truck, enabled: true },
{ href: "/review", label: t("review"), icon: LineChart, enabled: false },
];
+146
View File
@@ -0,0 +1,146 @@
import Link from "next/link";
import { getFormatter, getTranslations } from "next-intl/server";
import { Plus } from "lucide-react";
import { requireSession } from "@/server/auth";
import { dbForTenant, prisma } from "@/server/db";
import { hasPermission, requirePermission } from "@/server/rbac";
import { Button } from "@/components/ui/button";
import { CriticalityPill, KpiCard, PageHead, Pill } from "@/components/mockup-ui";
import {
SupplierCreateModal,
SupplierDetailModal,
SupplierEditModal,
} from "@/components/supplier-modals";
import { supplierRef, SUPPLIER_STATUS_TONE, isExpiring, isReviewDue } from "@/lib/supplier";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
const SUPPLIER_INCLUDE = {
assessments: true,
contracts: true,
ndas: true,
evidence: true,
responsibilities: true,
subcontractors: true,
decisions: true,
controlMaturity: true,
assetLinks: { include: { asset: { select: { id: true, name: true } } } },
} as const;
export default async function SuppliersPage({
searchParams,
}: {
searchParams: Promise<{ detail?: string; edit?: string; new?: string }>;
}) {
const session = await requireSession();
requirePermission(session, "supplier:read");
const t = await getTranslations("suppliers");
const tStatus = await getTranslations("supplierStatus");
const tCrit = await getTranslations("criticality");
const tc = await getTranslations("common");
const fmt = await getFormatter();
const params = await searchParams;
const db = dbForTenant(session.user.tenantId);
const canWrite = hasPermission(session, "supplier:write");
const suppliers = await db.supplier.findMany({
include: {
contracts: { select: { validTo: true } },
evidence: { select: { validTo: true } },
_count: { select: { assessments: true, contracts: true } },
},
orderBy: { refNo: "asc" },
take: 300,
});
const total = suppliers.length;
const nis2 = suppliers.filter((s) => s.nis2Relevant).length;
const expiring = suppliers.filter(
(s) =>
s.contracts.some((c) => isExpiring(c.validTo)) || s.evidence.some((e) => isExpiring(e.validTo))
).length;
const reviewsDue = suppliers.filter((s) => isReviewDue(s.nextReview)).length;
const controls = await prisma.supplierControl.findMany({ orderBy: { ref: "asc" } });
const modalId = params.edit && canWrite ? params.edit : params.detail;
const modalSupplier = modalId
? await db.supplier.findUnique({ where: { id: modalId }, include: SUPPLIER_INCLUDE })
: 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>
)
}
/>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<KpiCard label={t("kpiTotal")} value={total} />
<KpiCard label={t("kpiNis2")} value={nis2} trend="NIS2" trendColor="warn" />
<KpiCard label={t("kpiExpiring")} value={expiring} trendColor="warn" trend={expiring > 0 ? t("kpiExpiring") : undefined} />
<KpiCard label={t("kpiReviews")} value={reviewsDue} trendColor="risk" trend={reviewsDue > 0 ? t("kpiReviews") : undefined} />
</div>
<div className="shadow-card mt-4 rounded-xl border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("ref")}</TableHead>
<TableHead>{t("name")}</TableHead>
<TableHead>{t("sector")}</TableHead>
<TableHead>{t("criticality")}</TableHead>
<TableHead>NIS2</TableHead>
<TableHead>{t("nextReview")}</TableHead>
<TableHead>{t("status")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{suppliers.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="py-8 text-center text-muted-foreground">{t("empty")}</TableCell>
</TableRow>
)}
{suppliers.map((s) => (
<TableRow key={s.id}>
<TableCell className="text-muted-foreground">{supplierRef(s.refNo)}</TableCell>
<TableCell>
<Link href={`/suppliers?detail=${s.id}`} className="font-bold hover:underline">{s.name}</Link>
</TableCell>
<TableCell className="text-muted-foreground">{s.sector ?? tc("none")}</TableCell>
<TableCell><CriticalityPill level={s.criticality} label={tCrit(String(s.criticality))} /></TableCell>
<TableCell>{s.nis2Relevant ? <Pill tone="info">NIS2</Pill> : <span className="text-muted-foreground">{tc("none")}</span>}</TableCell>
<TableCell className={isReviewDue(s.nextReview) ? "text-[var(--warn)]" : "text-muted-foreground"}>
{s.nextReview ? fmt.dateTime(s.nextReview, { dateStyle: "medium" }) : tc("none")}
</TableCell>
<TableCell><Pill tone={SUPPLIER_STATUS_TONE[s.status]}>{tStatus(s.status)}</Pill></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{modalSupplier && params.edit && canWrite ? (
<SupplierEditModal supplier={modalSupplier} controls={controls} />
) : modalSupplier ? (
<SupplierDetailModal supplier={modalSupplier} controls={controls} canWrite={canWrite} />
) : params.new && canWrite ? (
<SupplierCreateModal />
) : null}
</main>
);
}
+651
View File
@@ -0,0 +1,651 @@
import Link from "next/link";
import { getFormatter, getTranslations } from "next-intl/server";
import { Pencil, Plus, Trash2, X, AlertTriangle } from "lucide-react";
import type { Prisma, SupplierControl } from "@prisma/client";
import {
addAssessment,
addContract,
addDecision,
addEvidence,
addNda,
addResponsibility,
addSubcontractor,
createSupplier,
deleteChild,
deleteSupplier,
saveMaturity,
updateSupplier,
} from "@/server/actions/suppliers";
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 { supplierRef, SUPPLIER_STATUS_TONE, isExpired, isExpiring, needsManagementDecision } from "@/lib/supplier";
export type SupplierWithDetail = Prisma.SupplierGetPayload<{
include: {
assessments: true;
contracts: true;
ndas: true;
evidence: true;
responsibilities: true;
subcontractors: true;
decisions: true;
controlMaturity: true;
assetLinks: { include: { asset: { select: { id: true; name: true } } } };
};
}>;
const STATUSES = ["ACTIVE", "ONBOARDING", "UNDER_REVIEW", "OFFBOARDED"] as const;
const inputCls = "h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm";
/* ─────────────────────────── Detail (read-only) ─────────────────────────── */
export async function SupplierDetailModal({
supplier,
controls,
canWrite,
}: {
supplier: SupplierWithDetail;
controls: SupplierControl[];
canWrite: boolean;
}) {
const t = await getTranslations("suppliers");
const tStatus = await getTranslations("supplierStatus");
const tCrit = await getTranslations("criticality");
const tAType = await getTranslations("assessmentType");
const tEKind = await getTranslations("evidenceKind");
const tParty = await getTranslations("responsibleParty");
const tc = await getTranslations("common");
const fmt = await getFormatter();
const date = (d: Date | null) => (d ? fmt.dateTime(d, { dateStyle: "medium" }) : tc("none"));
const decisionNeeded = needsManagementDecision(supplier.evidence);
const matById = new Map(supplier.controlMaturity.map((m) => [m.controlId, m.maturity]));
return (
<Modal
title={`${supplierRef(supplier.refNo)} · ${supplier.name}`}
sub={t("detailSub")}
headerExtra={
<span className="flex items-center gap-2">
{supplier.nis2Relevant && <Pill tone="info">NIS2</Pill>}
<CriticalityPill level={supplier.criticality} label={tCrit(String(supplier.criticality))} />
<Pill tone={SUPPLIER_STATUS_TONE[supplier.status]}>{tStatus(supplier.status)}</Pill>
</span>
}
closeHref="/suppliers"
closeLabel={t("close")}
footer={
<>
{canWrite && (
<Button variant="outline" nativeButton={false} render={<Link href={`/suppliers?edit=${supplier.id}`} />}>
<Pencil className="size-4" /> {tc("edit")}
</Button>
)}
<Button nativeButton={false} render={<Link href="/suppliers" />}>
{t("close")}
</Button>
</>
}
>
<div className="space-y-5 p-5">
{/* Stammdaten + Assets */}
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-xl border border-l-[3px] border-l-[var(--primary)] bg-[var(--surface-soft)] p-4 text-[12.5px]">
<dl className="grid grid-cols-[9rem_1fr] gap-1.5">
<dt className="text-muted-foreground">{t("sector")}</dt>
<dd>{supplier.sector ?? tc("none")}</dd>
<dt className="text-muted-foreground">{t("services")}</dt>
<dd>{supplier.services ?? tc("none")}</dd>
<dt className="text-muted-foreground">{t("contact")}</dt>
<dd>{supplier.contact ?? tc("none")}</dd>
<dt className="text-muted-foreground">{t("dataCategories")}</dt>
<dd>{supplier.dataCategories.join(", ") || tc("none")}</dd>
<dt className="text-muted-foreground">{t("protection")}</dt>
<dd><CiaBadge c={supplier.confidentiality} i={supplier.integrity} a={supplier.availability} /></dd>
<dt className="text-muted-foreground">{t("nextReview")}</dt>
<dd>{date(supplier.nextReview)}</dd>
</dl>
</div>
<div>
<p className="text-sm font-semibold">{t("assets")}</p>
{supplier.assetLinks.length === 0 && <p className="mt-1 text-sm text-muted-foreground">{tc("none")}</p>}
<ul className="mt-1.5 space-y-1 text-sm">
{supplier.assetLinks.map((l) => (
<li key={l.id}>
<Link href={`/assets?detail=${l.asset.id}`} className="hover:underline">{l.asset.name}</Link>
</li>
))}
</ul>
{supplier.notes && <p className="mt-3 text-[12.5px] text-muted-foreground">{supplier.notes}</p>}
</div>
</div>
{/* Managemententscheidung nötig? */}
{decisionNeeded && (
<div className="flex items-start gap-2.5 rounded-xl border border-[rgba(240,173,78,0.4)] bg-[rgba(240,173,78,0.12)] p-3.5 text-[12.5px] text-[var(--warn)]">
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
<span>{t("decisionNeeded")}</span>
</div>
)}
{/* VDA-ISA Kap. 6 Reifegrade */}
<section>
<p className="font-heading text-[15px] font-semibold">{t("catalog")}</p>
<p className="text-[12.5px] text-muted-foreground">{t("catalogNote")}</p>
<div className="mt-2 space-y-2">
{controls.map((c) => {
const m = matById.get(c.id) ?? 0;
return (
<div key={c.id} className="rounded-xl border p-3">
<div className="flex items-center justify-between gap-2">
<span className="text-[13px] font-semibold">
{c.ref} · {c.objective}
</span>
<span className="shrink-0 text-[12px] text-muted-foreground">
{t("maturity")} {m}/5 · {t("target")} {c.targetMaturity}
</span>
</div>
<div className="mt-2 flex gap-1">
{[1, 2, 3, 4, 5].map((s) => (
<span
key={s}
className="h-2 flex-1 rounded-full"
style={{
background:
s <= m
? m >= c.targetMaturity
? "var(--ok)"
: "var(--warn)"
: "rgba(120,135,180,0.2)",
}}
/>
))}
</div>
{c.references && (
<p className="mt-2 text-[11px] text-muted-foreground">{t("references")}: {c.references}</p>
)}
</div>
);
})}
</div>
</section>
{/* Nachweise */}
<ListSection title={t("evidence")}>
{supplier.evidence.length === 0 ? (
<Empty t={tc("none")} />
) : (
supplier.evidence.map((e) => (
<li key={e.id} className="flex flex-wrap items-center gap-2">
<Pill tone="violet">{tEKind(e.kind)}</Pill>
<span>{e.name ?? tEKind(e.kind)}</span>
{e.protectsCia && <span className="text-xs text-muted-foreground">({e.protectsCia})</span>}
{e.adequacyChecked && <Pill tone="ok">{t("adequacy")}</Pill>}
{e.validTo && (
<span className={isExpired(e.validTo) ? "text-[var(--risk)]" : isExpiring(e.validTo) ? "text-[var(--warn)]" : "text-muted-foreground"}>
{t("expires")} {date(e.validTo)}
</span>
)}
</li>
))
)}
</ListSection>
{/* Bewertungen */}
<ListSection title={t("assessments")}>
{supplier.assessments.length === 0 ? (
<Empty t={tc("none")} />
) : (
supplier.assessments.map((a) => (
<li key={a.id} className="flex flex-wrap items-center gap-2">
<Pill tone="info">{tAType(a.type)}</Pill>
{a.score != null && <span className="font-semibold">{a.score}/100</span>}
{a.result && <span>{a.result}</span>}
<span className="text-xs text-muted-foreground">{date(a.date)}</span>
</li>
))
)}
</ListSection>
{/* Verträge */}
<ListSection title={t("contracts")}>
{supplier.contracts.length === 0 ? (
<Empty t={tc("none")} />
) : (
supplier.contracts.map((k) => (
<li key={k.id} className="flex flex-wrap items-center gap-2">
<span className="font-medium">{k.reference ?? k.type}</span>
{k.avDpa && <Pill tone="ok">{t("avDpa")}</Pill>}
{k.flowdown && <Pill tone="info">{t("flowdown")}</Pill>}
{k.validTo && (
<span className={isExpiring(k.validTo) ? "text-[var(--warn)]" : "text-muted-foreground"}>
{t("validTo")}: {date(k.validTo)}
</span>
)}
</li>
))
)}
</ListSection>
{/* NDAs */}
<ListSection title={t("ndas")}>
{supplier.ndas.length === 0 ? (
<Empty t={tc("none")} />
) : (
supplier.ndas.map((n) => (
<li key={n.id} className="flex flex-wrap items-center gap-2">
<span className="font-medium">{n.subject ?? n.parties ?? "NDA"}</span>
{n.validTo && (
<span className={isExpiring(n.validTo) ? "text-[var(--warn)]" : "text-muted-foreground"}>
{t("validTo")}: {date(n.validTo)}
</span>
)}
{n.extensionStatus && <span className="text-xs text-muted-foreground">· {n.extensionStatus}</span>}
</li>
))
)}
</ListSection>
{/* RACI */}
<ListSection title={t("raci")}>
{supplier.responsibilities.length === 0 ? (
<Empty t={tc("none")} />
) : (
supplier.responsibilities.map((r) => (
<li key={r.id} className="flex flex-wrap items-center gap-2">
<span className="font-medium">{r.itService}</span>
<span className="text-muted-foreground">· {r.requirement}</span>
<Pill tone={r.responsibleParty === "SUPPLIER" ? "warn" : r.responsibleParty === "CLIENT" ? "info" : "violet"}>
{tParty(r.responsibleParty)}
</Pill>
</li>
))
)}
</ListSection>
{/* Subunternehmer */}
{supplier.subcontractors.length > 0 && (
<ListSection title={t("subcontractors")}>
{supplier.subcontractors.map((s) => (
<li key={s.id} className="flex items-center gap-2">
{s.name} {s.flowdownObligation && <Pill tone="ok">{t("flowdownObl")}</Pill>}
</li>
))}
</ListSection>
)}
{/* Entscheidungen */}
{supplier.decisions.length > 0 && (
<ListSection title={t("decision")}>
{supplier.decisions.map((d) => (
<li key={d.id}>
<span className="font-medium">{d.decision}</span>
<span className="text-muted-foreground"> {d.reasonNoAudit}</span>
<span className="text-xs text-muted-foreground"> · {d.decidedBy} · {date(d.date)}</span>
</li>
))}
</ListSection>
)}
</div>
</Modal>
);
}
function ListSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section>
<p className="text-sm font-semibold">{title}</p>
<ul className="mt-1.5 space-y-1.5 text-sm">{children}</ul>
</section>
);
}
function Empty({ t }: { t: string }) {
return <li className="text-muted-foreground">{t}</li>;
}
/* ─────────────────────────── Stammdaten-Formular ─────────────────────────── */
async function SupplierFields({ supplier, formId }: { supplier?: SupplierWithDetail; formId?: string }) {
const t = await getTranslations("suppliers");
const tStatus = await getTranslations("supplierStatus");
const f = formId ? { form: formId } : {};
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={supplier?.name} className="mt-1" {...f} />
</div>
<div>
<Label htmlFor="sector">{t("sector")}</Label>
<Input id="sector" name="sector" defaultValue={supplier?.sector ?? ""} className="mt-1" {...f} />
</div>
<div>
<Label htmlFor="contact">{t("contact")}</Label>
<Input id="contact" name="contact" defaultValue={supplier?.contact ?? ""} className="mt-1" {...f} />
</div>
<div className="md:col-span-2">
<Label htmlFor="services">{t("services")}</Label>
<Textarea id="services" name="services" rows={2} defaultValue={supplier?.services ?? ""} className="mt-1" {...f} />
</div>
<div>
<Label htmlFor="status">{t("status")}</Label>
<select id="status" name="status" defaultValue={supplier?.status ?? "ACTIVE"} className={`${inputCls} mt-1`} {...f}>
{STATUSES.map((v) => (
<option key={v} value={v}>{tStatus(v)}</option>
))}
</select>
</div>
<div>
<Label htmlFor="criticality">{t("criticality")}</Label>
<div className="mt-1">
<SegmentedRating name="criticality" defaultValue={supplier?.criticality ?? 1} form={formId} />
</div>
</div>
<div className="md:col-span-2">
<Label htmlFor="dataCategories">{t("dataCategories")}</Label>
<Input id="dataCategories" name="dataCategories" defaultValue={supplier?.dataCategories.join(", ") ?? ""} className="mt-1" {...f} />
</div>
<div>
<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={(supplier?.[n] as number) ?? 1} form={formId} />
))}
</div>
</div>
<div>
<Label htmlFor="nextReview">{t("nextReview")}</Label>
<Input id="nextReview" name="nextReview" type="date" defaultValue={supplier?.nextReview ? supplier.nextReview.toISOString().slice(0, 10) : ""} className="mt-1" {...f} />
</div>
<label className="flex items-center gap-2 text-sm md:col-span-2">
<input type="checkbox" name="nis2Relevant" defaultChecked={supplier?.nis2Relevant} {...f} /> {t("nis2")}
</label>
<div className="md:col-span-2">
<Label htmlFor="notes">{t("notes")}</Label>
<Textarea id="notes" name="notes" rows={2} defaultValue={supplier?.notes ?? ""} className="mt-1" {...f} />
</div>
</div>
);
}
export async function SupplierCreateModal() {
const t = await getTranslations("suppliers");
const tc = await getTranslations("common");
return (
<Modal title={t("createTitle")} closeHref="/suppliers" closeLabel={t("close")}>
<form action={createSupplier} className="space-y-4 p-5">
<SupplierFields />
<div className="flex gap-2 pt-1">
<Button type="submit">{tc("save")}</Button>
<Button variant="outline" nativeButton={false} render={<Link href="/suppliers" />}>{tc("cancel")}</Button>
</div>
</form>
</Modal>
);
}
/* ─────────────────────────── Bearbeiten ─────────────────────────── */
export async function SupplierEditModal({
supplier,
controls,
}: {
supplier: SupplierWithDetail;
controls: SupplierControl[];
}) {
const t = await getTranslations("suppliers");
const tc = await getTranslations("common");
const tEKind = await getTranslations("evidenceKind");
const tAType = await getTranslations("assessmentType");
const tParty = await getTranslations("responsibleParty");
const FORM = "supplier-edit";
const matById = new Map(supplier.controlMaturity.map((m) => [m.controlId, m.maturity]));
const disc =
"cursor-pointer list-none rounded-lg border border-[var(--panel-brd)] bg-[var(--elevated)] px-3 py-1.5 text-[12.5px] font-semibold text-muted-foreground hover:text-foreground [&::-webkit-details-marker]:hidden";
return (
<Modal
title={t("editTitle")}
sub={`${supplierRef(supplier.refNo)} · ${supplier.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={deleteSupplier.bind(null, supplier.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?detail=${supplier.id}`}
closeLabel={t("close")}
footer={
<>
<Button variant="outline" nativeButton={false} render={<Link href={`/suppliers?detail=${supplier.id}`} />}>{tc("cancel")}</Button>
<Button type="submit" form={FORM}>{tc("save")}</Button>
</>
}
>
<form id={FORM} action={updateSupplier.bind(null, supplier.id)} className="hidden" />
<div className="space-y-6 p-5">
<SupplierFields supplier={supplier} formId={FORM} />
{/* Reifegrade je Control */}
<section className="border-t pt-4">
<p className="font-heading text-[15px] font-semibold">{t("catalog")}</p>
<div className="mt-2 space-y-2">
{controls.map((c) => (
<form key={c.id} action={saveMaturity.bind(null, supplier.id, c.id)} className="flex items-center gap-3 text-sm">
<span className="flex-1"><b>{c.ref}</b> · {t("target")} {c.targetMaturity}</span>
<select name="maturity" defaultValue={matById.get(c.id) ?? 0} className={`${inputCls} w-28`}>
{[0, 1, 2, 3, 4, 5].map((v) => (
<option key={v} value={v}>{t("maturity")} {v}</option>
))}
</select>
<Button type="submit" variant="secondary" size="sm">{tc("save")}</Button>
</form>
))}
</div>
</section>
{/* Nachweise */}
<ChildSection
title={t("evidence")}
items={supplier.evidence.map((e) => ({
id: e.id,
label: `${tEKind(e.kind)} · ${e.name ?? ""}${e.adequacyChecked ? " ✓" : ""}`,
}))}
kind="evidence"
addLabel={t("addEvidence")}
discClass={disc}
>
<form action={addEvidence.bind(null, supplier.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">
<select name="kind" className={inputCls}>
{(["CERTIFICATE", "TISAX_LABEL", "ATTESTATION", "AUDIT_REPORT", "SELF_ASSESSMENT"] as const).map((k) => (
<option key={k} value={k}>{tEKind(k)}</option>
))}
</select>
<Input name="name" placeholder={t("name")} />
<Input name="protectsCia" placeholder={t("protectsCia")} defaultValue="C,I,A" />
<Input name="validTo" type="date" />
<label className="flex items-center gap-2 text-[12.5px]"><input type="checkbox" name="adequacyChecked" /> {t("adequacy")}</label>
<Button type="submit" variant="secondary" size="sm">{tc("add")}</Button>
</form>
</ChildSection>
{/* Bewertungen */}
<ChildSection
title={t("assessments")}
items={supplier.assessments.map((a) => ({ id: a.id, label: `${tAType(a.type)}${a.score != null ? ` · ${a.score}/100` : ""}` }))}
kind="assessment"
addLabel={t("addAssessment")}
discClass={disc}
>
<form action={addAssessment.bind(null, supplier.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">
<select name="type" className={inputCls}>
{(["QUESTIONNAIRE", "SELF_ASSESSMENT", "AUDIT"] as const).map((k) => (
<option key={k} value={k}>{tAType(k)}</option>
))}
</select>
<Input name="score" type="number" min={0} max={100} placeholder={t("score")} />
<Input name="date" type="date" />
<Input name="nextReview" type="date" placeholder={t("nextReview")} />
<Input name="result" placeholder={t("result")} />
<Button type="submit" variant="secondary" size="sm">{tc("add")}</Button>
</form>
</ChildSection>
{/* Verträge */}
<ChildSection
title={t("contracts")}
items={supplier.contracts.map((k) => ({ id: k.id, label: `${k.reference ?? k.type}${k.avDpa ? " · AV" : ""}` }))}
kind="contract"
addLabel={t("addContract")}
discClass={disc}
>
<form action={addContract.bind(null, supplier.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="reference" placeholder={t("reference")} />
<Input name="type" placeholder={t("type")} defaultValue="av_dpa" />
<div className="grid grid-cols-2 gap-1 text-[12px]">
<label className="flex items-center gap-1.5"><input type="checkbox" name="avDpa" defaultChecked /> {t("avDpa")}</label>
<label className="flex items-center gap-1.5"><input type="checkbox" name="securityClauses" /> {t("securityClauses")}</label>
<label className="flex items-center gap-1.5"><input type="checkbox" name="flowdown" /> {t("flowdown")}</label>
<label className="flex items-center gap-1.5"><input type="checkbox" name="customerTransparency" /> {t("customerTransparency")}</label>
</div>
<Input name="validFrom" type="date" />
<Input name="validTo" type="date" />
<Button type="submit" variant="secondary" size="sm">{tc("add")}</Button>
</form>
</ChildSection>
{/* NDAs */}
<ChildSection
title={t("ndas")}
items={supplier.ndas.map((n) => ({ id: n.id, label: n.subject ?? n.parties ?? "NDA" }))}
kind="nda"
addLabel={t("addNda")}
discClass={disc}
>
<form action={addNda.bind(null, supplier.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="parties" placeholder={t("parties")} />
<Input name="subject" placeholder={t("subject")} />
<Input name="infoScope" placeholder={t("infoScope")} />
<Input name="validFrom" type="date" />
<Input name="validTo" type="date" />
<Input name="extensionStatus" placeholder={t("extensionStatus")} />
<Button type="submit" variant="secondary" size="sm">{tc("add")}</Button>
</form>
</ChildSection>
{/* RACI */}
<ChildSection
title={t("raci")}
items={supplier.responsibilities.map((r) => ({ id: r.id, label: `${r.itService} · ${tParty(r.responsibleParty)}` }))}
kind="responsibility"
addLabel={t("addRaci")}
discClass={disc}
>
<form action={addResponsibility.bind(null, supplier.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="itService" required placeholder={t("itService")} />
<Input name="requirement" required placeholder={t("requirement")} />
<select name="responsibleParty" className={inputCls}>
{(["CLIENT", "SUPPLIER", "SHARED"] as const).map((k) => (
<option key={k} value={k}>{tParty(k)}</option>
))}
</select>
<Input name="isaApplicability" placeholder={t("isaApplicability")} />
<Input name="integratedLocalControls" placeholder={t("localControls")} />
<Button type="submit" variant="secondary" size="sm">{tc("add")}</Button>
</form>
</ChildSection>
{/* Subunternehmer */}
<ChildSection
title={t("subcontractors")}
items={supplier.subcontractors.map((s) => ({ id: s.id, label: s.name }))}
kind="subcontractor"
addLabel={t("addSub")}
discClass={disc}
>
<form action={addSubcontractor.bind(null, supplier.id)} className="shadow-card absolute right-0 z-10 mt-2 w-72 space-y-2 rounded-xl border bg-card p-3 text-sm">
<Input name="name" required placeholder={t("name")} />
<label className="flex items-center gap-2 text-[12.5px]"><input type="checkbox" name="flowdownObligation" defaultChecked /> {t("flowdownObl")}</label>
<Button type="submit" variant="secondary" size="sm">{tc("add")}</Button>
</form>
</ChildSection>
{/* Managemententscheidung */}
<ChildSection
title={t("decision")}
items={supplier.decisions.map((d) => ({ id: d.id, label: d.decision }))}
kind="decision"
addLabel={t("addDecision")}
discClass={disc}
>
<form action={addDecision.bind(null, supplier.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">
<Textarea name="reasonNoAudit" required rows={2} placeholder={t("reasonNoAudit")} />
<Textarea name="decision" required rows={2} placeholder={t("decisionText")} />
<Input name="decidedBy" placeholder={t("decidedBy")} />
<Input name="recordRef" placeholder={t("recordRef")} />
<Button type="submit" variant="secondary" size="sm">{tc("add")}</Button>
</form>
</ChildSection>
</div>
</Modal>
);
}
async function ChildSection({
title,
items,
kind,
addLabel,
discClass,
children,
}: {
title: string;
items: { id: string; label: string }[];
kind: "assessment" | "contract" | "nda" | "evidence" | "responsibility" | "subcontractor" | "decision";
addLabel: string;
discClass: string;
children: React.ReactNode;
}) {
const tc = await getTranslations("common");
return (
<section className="border-t pt-4 text-sm">
<div className="flex items-center justify-between gap-2">
<p className="font-heading text-[15px] font-semibold">{title}</p>
<details className="relative">
<summary className={discClass}>
<span className="inline-flex items-center gap-1.5"><Plus className="size-3.5" /> {addLabel}</span>
</summary>
{children}
</details>
</div>
{items.length > 0 && (
<ul className="mt-2 space-y-1.5">
{items.map((it) => (
<li key={it.id} className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate">{it.label}</span>
<form action={deleteChild.bind(null, kind, it.id)}>
<button type="submit" title={tc("remove")} className="text-muted-foreground hover:text-destructive">
<X className="size-3.5" />
</button>
</form>
</li>
))}
</ul>
)}
</section>
);
}
+45
View File
@@ -0,0 +1,45 @@
export function supplierRef(refNo: number): string {
return `L-${String(refNo).padStart(3, "0")}`;
}
export const SUPPLIER_STATUS_TONE = {
ACTIVE: "ok",
ONBOARDING: "info",
UNDER_REVIEW: "warn",
OFFBOARDED: "mut",
} as const;
const DAY = 24 * 3600 * 1000;
/** true, wenn valid_to innerhalb der nächsten `days` Tage liegt (oder bereits abgelaufen). */
export function isExpiring(validTo: Date | null | undefined, days = 90): boolean {
if (!validTo) return false;
return validTo.getTime() - Date.now() < days * DAY;
}
export function isExpired(validTo: Date | null | undefined): boolean {
if (!validTo) return false;
return validTo.getTime() < Date.now();
}
/** Review innerhalb der nächsten `days` Tage fällig (oder überfällig)? */
export function isReviewDue(nextReview: Date | null | undefined, days = 30): boolean {
if (!nextReview) return false;
return nextReview.getTime() < Date.now() + days * DAY;
}
/**
* NIS2/VDA-ISA 6.1.1: Ist eine risikobasierte Managemententscheidung nötig?
* wenn KEIN gültiger, angemessenheitsgeprüfter Audit-/TISAX-Nachweis vorliegt.
*/
export function needsManagementDecision(
evidence: { kind: string; adequacyChecked: boolean; validTo: Date | null }[]
): boolean {
const hasAdequate = evidence.some(
(e) =>
(e.kind === "AUDIT_REPORT" || e.kind === "TISAX_LABEL" || e.kind === "CERTIFICATE") &&
e.adequacyChecked &&
!isExpired(e.validTo)
);
return !hasAdequate;
}
+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",
]);
/**