Lieferantenmodul neu: Asset-basiert + Anforderungs-Engine (Phase 1)
Referenz: docs/ISMS-Lieferantenmanagement-GEFIM.html Architektur: Lieferanten (und IT-Services) sind jetzt Assets. - AssetType += IT_SERVICE; SupplierProfile/ITServiceProfile als 1:1- Erweiterung eines Asset; alle Kind-Entitäten (Contract, Nda, SupplierEvidence, SupplierAssessment, ManagementDecision, Subcontractor, ServiceControlResponsibility, MaturityAssessment) hängen am Asset (keine parallele Datenhaltung). Migration + RLS; Demo neu geseedet. - Lieferanten erscheinen im Asset-Inventar (Filter „Lieferant"/„IT-Service"), tragen C/I/A, nutzbar in Graph/BIA/Risiko. Anforderungs-Engine (Herzstück, src/lib/supplier.ts): - Schutzbedarf aus max C/I/A der verknüpften Assets → Stufe normal/hoch/ sehr hoch; schaltet Anforderungen gestuft scharf (must/should/high/veryhigh) - Flache Anforderungsliste (nach Themen gruppiert) mit Status-Ableitung aus den hinterlegten Objekten, Referenz + Herkunfts-Control (6.1.1–6.1.3) - Gate-Logik: sehr hoch ohne Audit/Label → Kompensation (Management- entscheidung UND verknüpftes Risiko); „Risiko anlegen" erzeugt echten Risk - Konformität (Konform/Teilweise/Lücken/Handlung nötig) + berechneter Reifegrad; ISB-Freigabe (Abweichung nur mit Begründung → Audit-Log) - Cockpit-Detail mit Schutzbedarf-Banner + Live-Simulation der Stufe Verifiziert: Register mit abgeleiteter Stufe/Reifegrad/Konformität, Cockpit-Simulation (Tiers werden inaktiv), Gate bei sehr hoch ohne Audit, „Risiko anlegen" erzeugt R-005 im Risikomodul. Offen (Phase 2): Fragebogen-Builder, Self-Service-Portal, IT-Service- Detail mit RACI-Matrix, Klick im Asset-Inventar öffnet Cockpit, Scheduler-Automatisierung. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
32ab91efbf
commit
dc884062b2
File diff suppressed because one or more lines are too long
+13
-2
@@ -121,7 +121,8 @@
|
|||||||
"LOCATION": "Standort",
|
"LOCATION": "Standort",
|
||||||
"SUPPLIER": "Lieferant",
|
"SUPPLIER": "Lieferant",
|
||||||
"PERSON": "Person/Rolle",
|
"PERSON": "Person/Rolle",
|
||||||
"DATA": "Datenkategorie"
|
"DATA": "Datenkategorie",
|
||||||
|
"IT_SERVICE": "IT-Service"
|
||||||
},
|
},
|
||||||
"assetStatus": {
|
"assetStatus": {
|
||||||
"ACTIVE": "Aktiv",
|
"ACTIVE": "Aktiv",
|
||||||
@@ -428,7 +429,17 @@
|
|||||||
"assets": "Betroffene Assets",
|
"assets": "Betroffene Assets",
|
||||||
"close": "Schließen",
|
"close": "Schließen",
|
||||||
"none": "—",
|
"none": "—",
|
||||||
"add": "Hinzufügen"
|
"add": "Hinzufügen",
|
||||||
|
"risks": "Risiken",
|
||||||
|
"isbApproval": "ISB-Freigabe Reifegrad",
|
||||||
|
"isbValue": "ISB-Wert",
|
||||||
|
"justification": "Begründung (bei Abweichung Pflicht)",
|
||||||
|
"approve": "Freigeben",
|
||||||
|
"approvalNote": "Abweichung vom berechneten Wert wird mit Begründung im Audit-Log erfasst.",
|
||||||
|
"customerReq": "Kundenanforderungen weitergegeben",
|
||||||
|
"linkedAssets": "Verknüpfte Assets",
|
||||||
|
"derivedLevel": "Abgeleitete Stufe",
|
||||||
|
"conformity": "Konformität"
|
||||||
},
|
},
|
||||||
"assessmentType": {
|
"assessmentType": {
|
||||||
"QUESTIONNAIRE": "Fragebogen",
|
"QUESTIONNAIRE": "Fragebogen",
|
||||||
|
|||||||
+13
-2
@@ -121,7 +121,8 @@
|
|||||||
"LOCATION": "Location",
|
"LOCATION": "Location",
|
||||||
"SUPPLIER": "Supplier",
|
"SUPPLIER": "Supplier",
|
||||||
"PERSON": "Person/role",
|
"PERSON": "Person/role",
|
||||||
"DATA": "Data category"
|
"DATA": "Data category",
|
||||||
|
"IT_SERVICE": "IT service"
|
||||||
},
|
},
|
||||||
"assetStatus": {
|
"assetStatus": {
|
||||||
"ACTIVE": "Active",
|
"ACTIVE": "Active",
|
||||||
@@ -428,7 +429,17 @@
|
|||||||
"assets": "Affected assets",
|
"assets": "Affected assets",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"none": "—",
|
"none": "—",
|
||||||
"add": "Add"
|
"add": "Add",
|
||||||
|
"risks": "Risks",
|
||||||
|
"isbApproval": "ISB maturity approval",
|
||||||
|
"isbValue": "ISB value",
|
||||||
|
"justification": "Justification (required on deviation)",
|
||||||
|
"approve": "Approve",
|
||||||
|
"approvalNote": "Deviation from the computed value is recorded with justification in the audit log.",
|
||||||
|
"customerReq": "Customer requirements passed",
|
||||||
|
"linkedAssets": "Linked assets",
|
||||||
|
"derivedLevel": "Derived level",
|
||||||
|
"conformity": "Conformity"
|
||||||
},
|
},
|
||||||
"assessmentType": {
|
"assessmentType": {
|
||||||
"QUESTIONNAIRE": "Questionnaire",
|
"QUESTIONNAIRE": "Questionnaire",
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "SupplierLifecycle" AS ENUM ('ACTIVE', 'ONBOARDING', 'UNDER_REVIEW', 'OFFBOARDED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "RaciResponsibility" AS ENUM ('PROVIDER', 'US', 'SHARED');
|
||||||
|
|
||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "AssetType" ADD VALUE 'IT_SERVICE';
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "contracts" DROP CONSTRAINT "contracts_supplier_id_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "management_decisions" DROP CONSTRAINT "management_decisions_supplier_id_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "ndas" DROP CONSTRAINT "ndas_supplier_id_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "service_responsibilities" DROP CONSTRAINT "service_responsibilities_supplier_id_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "subcontractors" DROP CONSTRAINT "subcontractors_supplier_id_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "supplier_assessments" DROP CONSTRAINT "supplier_assessments_supplier_id_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "supplier_assets" DROP CONSTRAINT "supplier_assets_asset_id_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "supplier_assets" DROP CONSTRAINT "supplier_assets_supplier_id_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "supplier_control_maturity" DROP CONSTRAINT "supplier_control_maturity_control_id_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "supplier_control_maturity" DROP CONSTRAINT "supplier_control_maturity_supplier_id_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "supplier_evidence" DROP CONSTRAINT "supplier_evidence_supplier_id_fkey";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "contracts" DROP COLUMN "customer_transparency",
|
||||||
|
DROP COLUMN "supplier_id",
|
||||||
|
ADD COLUMN "asset_id" TEXT NOT NULL,
|
||||||
|
ADD COLUMN "customer_requirements_passed" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "management_decisions" DROP COLUMN "supplier_id",
|
||||||
|
ADD COLUMN "asset_id" TEXT NOT NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "ndas" DROP COLUMN "supplier_id",
|
||||||
|
ADD COLUMN "asset_id" TEXT NOT NULL,
|
||||||
|
ADD COLUMN "beyond_term" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "subcontractors" DROP COLUMN "supplier_id",
|
||||||
|
ADD COLUMN "asset_id" TEXT NOT NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "supplier_assessments" DROP COLUMN "status",
|
||||||
|
DROP COLUMN "supplier_id",
|
||||||
|
ADD COLUMN "asset_id" TEXT NOT NULL,
|
||||||
|
ADD COLUMN "self_score" INTEGER,
|
||||||
|
ADD COLUMN "verified_score" INTEGER;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "supplier_evidence" DROP COLUMN "supplier_id",
|
||||||
|
ADD COLUMN "asset_id" TEXT NOT NULL;
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "service_responsibilities";
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "supplier_assets";
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "supplier_control_maturity";
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "supplier_controls";
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "suppliers";
|
||||||
|
|
||||||
|
-- DropEnum
|
||||||
|
DROP TYPE "AssessmentStatus";
|
||||||
|
|
||||||
|
-- DropEnum
|
||||||
|
DROP TYPE "ResponsibleParty";
|
||||||
|
|
||||||
|
-- DropEnum
|
||||||
|
DROP TYPE "SupplierStatus";
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "supplier_profiles" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tenant_id" TEXT NOT NULL,
|
||||||
|
"asset_id" TEXT NOT NULL,
|
||||||
|
"ref_no" INTEGER NOT NULL,
|
||||||
|
"sector" TEXT,
|
||||||
|
"service_desc" TEXT,
|
||||||
|
"criticality" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"data_categories" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"nis2_relevant" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"lifecycle" "SupplierLifecycle" NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
"contact" TEXT,
|
||||||
|
"tisax_label" TEXT,
|
||||||
|
"tisax_valid_to" TIMESTAMP(3),
|
||||||
|
"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 "supplier_profiles_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "it_service_profiles" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tenant_id" TEXT NOT NULL,
|
||||||
|
"asset_id" TEXT NOT NULL,
|
||||||
|
"ref_no" INTEGER NOT NULL,
|
||||||
|
"provider_asset_id" TEXT,
|
||||||
|
"criticality" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"internal" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"notes" TEXT,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
"created_by" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "it_service_profiles_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "service_control_responsibilities" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tenant_id" TEXT NOT NULL,
|
||||||
|
"asset_id" TEXT NOT NULL,
|
||||||
|
"control_ref" TEXT NOT NULL,
|
||||||
|
"title" TEXT,
|
||||||
|
"applicable" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"responsibility" "RaciResponsibility" NOT NULL DEFAULT 'SHARED',
|
||||||
|
"evidence_ref" TEXT,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "service_control_responsibilities_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "maturity_assessments" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tenant_id" TEXT NOT NULL,
|
||||||
|
"asset_id" TEXT NOT NULL,
|
||||||
|
"self_value" DOUBLE PRECISION,
|
||||||
|
"verified_value" DOUBLE PRECISION,
|
||||||
|
"computed_value" DOUBLE PRECISION,
|
||||||
|
"isb_value" DOUBLE PRECISION,
|
||||||
|
"isb_justification" TEXT,
|
||||||
|
"approved_by" TEXT,
|
||||||
|
"approved_at" TIMESTAMP(3),
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "maturity_assessments_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "supplier_profiles_asset_id_key" ON "supplier_profiles"("asset_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "supplier_profiles_tenant_id_idx" ON "supplier_profiles"("tenant_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "supplier_profiles_tenant_id_ref_no_key" ON "supplier_profiles"("tenant_id", "ref_no");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "it_service_profiles_asset_id_key" ON "it_service_profiles"("asset_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "it_service_profiles_tenant_id_idx" ON "it_service_profiles"("tenant_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "it_service_profiles_tenant_id_ref_no_key" ON "it_service_profiles"("tenant_id", "ref_no");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "service_control_responsibilities_tenant_id_idx" ON "service_control_responsibilities"("tenant_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "maturity_assessments_asset_id_key" ON "maturity_assessments"("asset_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "maturity_assessments_tenant_id_idx" ON "maturity_assessments"("tenant_id");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "supplier_profiles" ADD CONSTRAINT "supplier_profiles_asset_id_fkey" FOREIGN KEY ("asset_id") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "it_service_profiles" ADD CONSTRAINT "it_service_profiles_asset_id_fkey" FOREIGN KEY ("asset_id") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "it_service_profiles" ADD CONSTRAINT "it_service_profiles_provider_asset_id_fkey" FOREIGN KEY ("provider_asset_id") REFERENCES "assets"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "supplier_assessments" ADD CONSTRAINT "supplier_assessments_asset_id_fkey" FOREIGN KEY ("asset_id") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "supplier_evidence" ADD CONSTRAINT "supplier_evidence_asset_id_fkey" FOREIGN KEY ("asset_id") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "management_decisions" ADD CONSTRAINT "management_decisions_asset_id_fkey" FOREIGN KEY ("asset_id") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "contracts" ADD CONSTRAINT "contracts_asset_id_fkey" FOREIGN KEY ("asset_id") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ndas" ADD CONSTRAINT "ndas_asset_id_fkey" FOREIGN KEY ("asset_id") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "subcontractors" ADD CONSTRAINT "subcontractors_asset_id_fkey" FOREIGN KEY ("asset_id") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "service_control_responsibilities" ADD CONSTRAINT "service_control_responsibilities_asset_id_fkey" FOREIGN KEY ("asset_id") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "maturity_assessments" ADD CONSTRAINT "maturity_assessments_asset_id_fkey" FOREIGN KEY ("asset_id") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
|
||||||
|
-- RLS für neue/umgestellte Lieferanten-Tabellen
|
||||||
|
DO $$
|
||||||
|
DECLARE t text;
|
||||||
|
BEGIN
|
||||||
|
FOREACH t IN ARRAY ARRAY['supplier_profiles','it_service_profiles','maturity_assessments','service_control_responsibilities'] 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 $$;
|
||||||
+124
-130
@@ -122,6 +122,7 @@ enum AssetType {
|
|||||||
APPLICATION
|
APPLICATION
|
||||||
LOCATION
|
LOCATION
|
||||||
SUPPLIER
|
SUPPLIER
|
||||||
|
IT_SERVICE
|
||||||
PERSON
|
PERSON
|
||||||
DATA
|
DATA
|
||||||
}
|
}
|
||||||
@@ -166,7 +167,19 @@ model Asset {
|
|||||||
relationsTo AssetRelation[] @relation("relationTo")
|
relationsTo AssetRelation[] @relation("relationTo")
|
||||||
processAssets ProcessAsset[]
|
processAssets ProcessAsset[]
|
||||||
riskAssets RiskAsset[]
|
riskAssets RiskAsset[]
|
||||||
supplierLinks SupplierAsset[]
|
|
||||||
|
// Lieferanten-/IT-Service-Erweiterungen (1:1) + Kind-Entitäten
|
||||||
|
supplierProfile SupplierProfile?
|
||||||
|
serviceProfile ITServiceProfile? @relation("serviceAsset")
|
||||||
|
serviceProvided ITServiceProfile[] @relation("serviceProvider")
|
||||||
|
assessments SupplierAssessment[]
|
||||||
|
evidence SupplierEvidence[]
|
||||||
|
decisions ManagementDecision[]
|
||||||
|
contracts Contract[]
|
||||||
|
ndas Nda[]
|
||||||
|
subcontractors Subcontractor[]
|
||||||
|
raci ServiceControlResponsibility[]
|
||||||
|
maturity MaturityAssessment?
|
||||||
|
|
||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
@@index([tenantId, type])
|
@@index([tenantId, type])
|
||||||
@@ -397,9 +410,12 @@ model Vulnerability {
|
|||||||
@@map("vulnerabilities")
|
@@map("vulnerabilities")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Lieferantenmanagement (SPEC §4.14, VDA-ISA 2027 Kap. 6, NIS2 Art. 21(2)(d)) ──
|
// ── Lieferanten- & IT-Service-Management (VDA-ISA 2027 Kap. 6, NIS2 Art. 21(2)(d)) ──
|
||||||
|
// Grundsatz: Lieferanten UND IT-Services sind Assets (type SUPPLIER | IT_SERVICE).
|
||||||
|
// SupplierProfile/ITServiceProfile sind 1:1-Erweiterungen eines Asset; alle
|
||||||
|
// Kind-Entitäten hängen am Asset (subject) — keine parallele Datenhaltung.
|
||||||
|
|
||||||
enum SupplierStatus {
|
enum SupplierLifecycle {
|
||||||
ACTIVE
|
ACTIVE
|
||||||
ONBOARDING
|
ONBOARDING
|
||||||
UNDER_REVIEW
|
UNDER_REVIEW
|
||||||
@@ -412,13 +428,6 @@ enum AssessmentType {
|
|||||||
AUDIT
|
AUDIT
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AssessmentStatus {
|
|
||||||
SENT
|
|
||||||
RECEIVED
|
|
||||||
EVALUATED
|
|
||||||
OVERDUE
|
|
||||||
}
|
|
||||||
|
|
||||||
enum EvidenceKind {
|
enum EvidenceKind {
|
||||||
CERTIFICATE
|
CERTIFICATE
|
||||||
TISAX_LABEL
|
TISAX_LABEL
|
||||||
@@ -427,29 +436,27 @@ enum EvidenceKind {
|
|||||||
SELF_ASSESSMENT
|
SELF_ASSESSMENT
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ResponsibleParty {
|
enum RaciResponsibility {
|
||||||
CLIENT
|
PROVIDER
|
||||||
SUPPLIER
|
US
|
||||||
SHARED
|
SHARED
|
||||||
}
|
}
|
||||||
|
|
||||||
model Supplier {
|
model SupplierProfile {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
tenantId String @map("tenant_id")
|
tenantId String @map("tenant_id")
|
||||||
refNo Int @map("ref_no") // Anzeige "L-001"
|
assetId String @unique @map("asset_id")
|
||||||
|
refNo Int @map("ref_no")
|
||||||
|
|
||||||
name String
|
|
||||||
sector String?
|
sector String?
|
||||||
services String? // erbrachte Leistung / IT-Services
|
serviceDesc String? @map("service_desc")
|
||||||
criticality Int @default(1) // 1–4
|
criticality Int @default(1)
|
||||||
dataCategories String[] @default([]) @map("data_categories")
|
dataCategories String[] @default([]) @map("data_categories")
|
||||||
// Schutzbedarf der verarbeiteten Informationen (C/I/A 1–4)
|
nis2Relevant Boolean @default(false) @map("nis2_relevant")
|
||||||
confidentiality Int @default(1)
|
lifecycle SupplierLifecycle @default(ACTIVE)
|
||||||
integrity Int @default(1)
|
|
||||||
availability Int @default(1)
|
|
||||||
nis2Relevant Boolean @default(false) @map("nis2_relevant") // Teil der Lieferkette
|
|
||||||
status SupplierStatus @default(ACTIVE)
|
|
||||||
contact String?
|
contact String?
|
||||||
|
tisaxLabel String? @map("tisax_label")
|
||||||
|
tisaxValidTo DateTime? @map("tisax_valid_to")
|
||||||
nextReview DateTime? @map("next_review")
|
nextReview DateTime? @map("next_review")
|
||||||
notes String?
|
notes String?
|
||||||
|
|
||||||
@@ -457,69 +464,104 @@ model Supplier {
|
|||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
createdBy String? @map("created_by")
|
createdBy String? @map("created_by")
|
||||||
|
|
||||||
assessments SupplierAssessment[]
|
asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade)
|
||||||
contracts Contract[]
|
|
||||||
ndas Nda[]
|
|
||||||
evidence SupplierEvidence[]
|
|
||||||
responsibilities ServiceResponsibility[]
|
|
||||||
subcontractors Subcontractor[]
|
|
||||||
decisions ManagementDecision[]
|
|
||||||
controlMaturity SupplierControlMaturity[]
|
|
||||||
assetLinks SupplierAsset[]
|
|
||||||
|
|
||||||
@@unique([tenantId, refNo])
|
@@unique([tenantId, refNo])
|
||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
@@map("suppliers")
|
@@map("supplier_profiles")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lieferant ↔ Asset (welcher Dienstleister betrifft welche Assets) — speist den Graph
|
model ITServiceProfile {
|
||||||
model SupplierAsset {
|
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
tenantId String @map("tenant_id")
|
tenantId String @map("tenant_id")
|
||||||
supplierId String @map("supplier_id")
|
assetId String @unique @map("asset_id")
|
||||||
assetId String @map("asset_id")
|
refNo Int @map("ref_no")
|
||||||
|
providerAssetId String? @map("provider_asset_id") // Asset(type SUPPLIER)
|
||||||
|
criticality Int @default(1)
|
||||||
|
internal Boolean @default(false)
|
||||||
|
notes String?
|
||||||
|
|
||||||
supplier Supplier @relation(fields: [supplierId], references: [id], onDelete: Cascade)
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade)
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
createdBy String? @map("created_by")
|
||||||
|
|
||||||
@@unique([supplierId, assetId])
|
asset Asset @relation("serviceAsset", fields: [assetId], references: [id], onDelete: Cascade)
|
||||||
|
provider Asset? @relation("serviceProvider", fields: [providerAssetId], references: [id])
|
||||||
|
|
||||||
|
@@unique([tenantId, refNo])
|
||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
@@map("supplier_assets")
|
@@map("it_service_profiles")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bewertung & Nachweise (6.1.1)
|
||||||
model SupplierAssessment {
|
model SupplierAssessment {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
tenantId String @map("tenant_id")
|
tenantId String @map("tenant_id")
|
||||||
supplierId String @map("supplier_id")
|
assetId String @map("asset_id")
|
||||||
type AssessmentType
|
type AssessmentType
|
||||||
status AssessmentStatus @default(SENT)
|
score Int?
|
||||||
score Int? // 0–100 Scoring
|
selfScore Int? @map("self_score")
|
||||||
|
verifiedScore Int? @map("verified_score")
|
||||||
date DateTime?
|
date DateTime?
|
||||||
nextReview DateTime? @map("next_review")
|
nextReview DateTime? @map("next_review")
|
||||||
result String?
|
result String?
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
supplier Supplier @relation(fields: [supplierId], references: [id], onDelete: Cascade)
|
asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
@@map("supplier_assessments")
|
@@map("supplier_assessments")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model SupplierEvidence {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
tenantId String @map("tenant_id")
|
||||||
|
assetId String @map("asset_id") // Subjekt (Lieferant ODER Service)
|
||||||
|
kind EvidenceKind
|
||||||
|
name String?
|
||||||
|
protectsCia String? @map("protects_cia")
|
||||||
|
validTo DateTime? @map("valid_to")
|
||||||
|
adequacyChecked Boolean @default(false) @map("adequacy_checked")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([tenantId])
|
||||||
|
@@map("supplier_evidence")
|
||||||
|
}
|
||||||
|
|
||||||
|
model ManagementDecision {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
tenantId String @map("tenant_id")
|
||||||
|
assetId String @map("asset_id")
|
||||||
|
reasonNoAudit String @map("reason_no_audit")
|
||||||
|
decision String
|
||||||
|
decidedBy String? @map("decided_by")
|
||||||
|
date DateTime @default(now())
|
||||||
|
recordRef String? @map("record_ref")
|
||||||
|
|
||||||
|
asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([tenantId])
|
||||||
|
@@map("management_decisions")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vertrag & Geheimhaltung (6.1.1 / 6.1.2)
|
||||||
model Contract {
|
model Contract {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
tenantId String @map("tenant_id")
|
tenantId String @map("tenant_id")
|
||||||
supplierId String @map("supplier_id")
|
assetId String @map("asset_id")
|
||||||
type String @default("service") // service | av_dpa | nda | sla …
|
type String @default("service")
|
||||||
avDpa Boolean @default(false) @map("av_dpa") // AV/DPA (DSGVO Art. 28)
|
avDpa Boolean @default(false) @map("av_dpa")
|
||||||
securityClauses Boolean @default(false) @map("security_clauses")
|
securityClauses Boolean @default(false) @map("security_clauses")
|
||||||
flowdown Boolean @default(false) // Weitergabe an Subunternehmer
|
flowdown Boolean @default(false)
|
||||||
customerTransparency Boolean @default(false) @map("customer_transparency")
|
customerRequirementsPassed Boolean @default(false) @map("customer_requirements_passed")
|
||||||
validFrom DateTime? @map("valid_from")
|
validFrom DateTime? @map("valid_from")
|
||||||
validTo DateTime? @map("valid_to")
|
validTo DateTime? @map("valid_to")
|
||||||
reference String?
|
reference String?
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
supplier Supplier @relation(fields: [supplierId], references: [id], onDelete: Cascade)
|
asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
@@map("contracts")
|
@@map("contracts")
|
||||||
@@ -528,120 +570,72 @@ model Contract {
|
|||||||
model Nda {
|
model Nda {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
tenantId String @map("tenant_id")
|
tenantId String @map("tenant_id")
|
||||||
supplierId String @map("supplier_id")
|
assetId String @map("asset_id")
|
||||||
parties String?
|
parties String?
|
||||||
infoScope String? @map("info_scope")
|
infoScope String? @map("info_scope")
|
||||||
subject String?
|
subject String?
|
||||||
validFrom DateTime? @map("valid_from")
|
validFrom DateTime? @map("valid_from")
|
||||||
validTo DateTime? @map("valid_to")
|
validTo DateTime? @map("valid_to")
|
||||||
obligations String?
|
obligations String?
|
||||||
extensionStatus String? @map("extension_status") // z. B. offen | verlängert | ausgelaufen
|
beyondTerm Boolean @default(false) @map("beyond_term")
|
||||||
|
extensionStatus String? @map("extension_status")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
supplier Supplier @relation(fields: [supplierId], references: [id], onDelete: Cascade)
|
asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
@@map("ndas")
|
@@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 {
|
model Subcontractor {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
tenantId String @map("tenant_id")
|
tenantId String @map("tenant_id")
|
||||||
supplierId String @map("supplier_id")
|
assetId String @map("asset_id")
|
||||||
name String
|
name String
|
||||||
flowdownObligation Boolean @default(false) @map("flowdown_obligation")
|
flowdownObligation Boolean @default(false) @map("flowdown_obligation")
|
||||||
|
|
||||||
supplier Supplier @relation(fields: [supplierId], references: [id], onDelete: Cascade)
|
asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
@@map("subcontractors")
|
@@map("subcontractors")
|
||||||
}
|
}
|
||||||
|
|
||||||
model ManagementDecision {
|
// Verantwortung je IT-Service (6.1.3) — RACI über den ISA-Control-Katalog
|
||||||
|
model ServiceControlResponsibility {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
tenantId String @map("tenant_id")
|
tenantId String @map("tenant_id")
|
||||||
supplierId String @map("supplier_id")
|
assetId String @map("asset_id") // Asset(type IT_SERVICE)
|
||||||
reasonNoAudit String @map("reason_no_audit")
|
controlRef String @map("control_ref")
|
||||||
decision String
|
title String?
|
||||||
decidedBy String? @map("decided_by")
|
applicable Boolean @default(true)
|
||||||
date DateTime @default(now())
|
responsibility RaciResponsibility @default(SHARED)
|
||||||
recordRef String? @map("record_ref")
|
evidenceRef String? @map("evidence_ref")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
supplier Supplier @relation(fields: [supplierId], references: [id], onDelete: Cascade)
|
asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
@@map("management_decisions")
|
@@map("service_control_responsibilities")
|
||||||
}
|
}
|
||||||
|
|
||||||
// VDA-ISA 2027 Kapitel 6 — Supplier Relationships (globaler Katalog, per Import versioniert)
|
// Reifegrad je Subjekt — Fragebogen-Selbstauskunft, durch Nachweise verifiziert, ISB-Freigabe
|
||||||
model SupplierControl {
|
model MaturityAssessment {
|
||||||
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())
|
id String @id @default(cuid())
|
||||||
tenantId String @map("tenant_id")
|
tenantId String @map("tenant_id")
|
||||||
supplierId String @map("supplier_id")
|
assetId String @unique @map("asset_id")
|
||||||
controlId String @map("control_id")
|
selfValue Float? @map("self_value")
|
||||||
maturity Int @default(0) // 0–5
|
verifiedValue Float? @map("verified_value")
|
||||||
notes String?
|
computedValue Float? @map("computed_value")
|
||||||
|
isbValue Float? @map("isb_value")
|
||||||
|
isbJustification String? @map("isb_justification")
|
||||||
|
approvedBy String? @map("approved_by")
|
||||||
|
approvedAt DateTime? @map("approved_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
supplier Supplier @relation(fields: [supplierId], references: [id], onDelete: Cascade)
|
asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade)
|
||||||
control SupplierControl @relation(fields: [controlId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@unique([supplierId, controlId])
|
|
||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
@@map("supplier_control_maturity")
|
@@map("maturity_assessments")
|
||||||
}
|
}
|
||||||
|
|
||||||
model AuditLog {
|
model AuditLog {
|
||||||
|
|||||||
+20
-157
@@ -453,168 +453,31 @@ async function main() {
|
|||||||
console.log(`✔ ${measures.length} Beispiel-Maßnahmen angelegt, Rest-Risiken berechnet`);
|
console.log(`✔ ${measures.length} Beispiel-Maßnahmen angelegt, Rest-Risiken berechnet`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. VDA-ISA 2027 Kapitel 6 — Supplier Relationships (globaler Katalog)
|
// 8. Demo-Lieferanten als Assets (type SUPPLIER) mit Profil, Nachweisen, Verträgen
|
||||||
const controls = [
|
const supCount = await prisma.supplierProfile.count({ where: { tenantId: tenant.id } });
|
||||||
{
|
if (supCount === 0) {
|
||||||
ref: "6.1.1",
|
const hoster = await prisma.asset.findFirst({ where: { tenantId: tenant.id, name: "Cloud-Hoster (IaaS)" } });
|
||||||
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 Sicherheitsrisikobewertung 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 Managemententscheidung mit Protokoll (C,I,A); Transparenzpflichten gegenüber Kunden erfüllt.",
|
|
||||||
targetMaturity: 3,
|
|
||||||
simplifiedGroupAssessment: true,
|
|
||||||
references:
|
|
||||||
"ISO 27001:2022 A.5.19–A.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:
|
|
||||||
"Geheimhaltungsanforderungen 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 a1 = await prisma.asset.create({
|
||||||
const supplierCount = await prisma.supplier.count({ where: { tenantId: tenant.id } });
|
data: { tenantId: tenant.id, name: "Cloud-Hoster GmbH", type: "SUPPLIER", confidentiality: 3, integrity: 3, availability: 4 },
|
||||||
if (supplierCount === 0) {
|
|
||||||
const hoster = await prisma.asset.findFirst({
|
|
||||||
where: { tenantId: tenant.id, name: "Cloud-Hoster (IaaS)" },
|
|
||||||
});
|
});
|
||||||
const sup = await prisma.supplier.create({
|
await prisma.supplierProfile.create({
|
||||||
data: {
|
data: { tenantId: tenant.id, assetId: a1.id, refNo: 1, sector: "IT-Dienstleistung / IaaS", serviceDesc: "Rechenzentrum, Virtualisierung, Backup", criticality: 4, dataCategories: ["Kundendaten", "Auftragsdaten"], nis2Relevant: true, lifecycle: "ACTIVE", contact: "security@cloud-hoster.example", nextReview: new Date(Date.now() + 90 * 24 * 3600 * 1000) },
|
||||||
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) {
|
if (hoster) await prisma.assetRelation.create({ data: { tenantId: tenant.id, assetId: a1.id, relatedAssetId: hoster.id, type: "provides" } }).catch(() => {});
|
||||||
await prisma.supplierAsset.create({
|
await prisma.supplierEvidence.create({ data: { tenantId: tenant.id, assetId: a1.id, kind: "TISAX_LABEL", name: "TISAX AL3 (info high)", protectsCia: "C,I,A", validTo: new Date(Date.now() + 200 * 24 * 3600 * 1000), adequacyChecked: true } });
|
||||||
data: { tenantId: tenant.id, supplierId: sup.id, assetId: hoster.id },
|
await prisma.contract.create({ data: { tenantId: tenant.id, assetId: a1.id, type: "av_dpa", avDpa: true, securityClauses: true, flowdown: true, customerRequirementsPassed: true, validTo: new Date(Date.now() + 400 * 24 * 3600 * 1000), reference: "AV-2025-014" } });
|
||||||
|
await prisma.nda.create({ data: { tenantId: tenant.id, assetId: a1.id, subject: "Betrieb der ERP-/CRM-Infrastruktur", parties: "Demo GmbH / Cloud-Hoster GmbH", validTo: new Date(Date.now() + 60 * 24 * 3600 * 1000), extensionStatus: "offen" } });
|
||||||
|
await prisma.supplierAssessment.create({ data: { tenantId: tenant.id, assetId: a1.id, type: "SELF_ASSESSMENT", score: 82, date: new Date(Date.now() - 120 * 24 * 3600 * 1000), nextReview: new Date(Date.now() + 245 * 24 * 3600 * 1000), result: "angemessen" } });
|
||||||
|
|
||||||
|
const a2 = await prisma.asset.create({
|
||||||
|
data: { tenantId: tenant.id, name: "WebAgentur X", type: "SUPPLIER", confidentiality: 2, integrity: 2, availability: 2 },
|
||||||
});
|
});
|
||||||
}
|
await prisma.supplierProfile.create({
|
||||||
await prisma.supplierEvidence.create({
|
data: { tenantId: tenant.id, assetId: a2.id, refNo: 2, sector: "Webentwicklung", serviceDesc: "Website-Betrieb", criticality: 2, dataCategories: ["Marketingdaten"], nis2Relevant: false, lifecycle: "UNDER_REVIEW" },
|
||||||
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: {
|
console.log("✔ 2 Demo-Lieferanten als Assets angelegt");
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
|
|
||||||
const ASSET_TYPES = ["INFORMATION", "SYSTEM", "APPLICATION", "LOCATION", "SUPPLIER", "PERSON", "DATA"] as const;
|
const ASSET_TYPES = ["INFORMATION", "SYSTEM", "APPLICATION", "LOCATION", "SUPPLIER", "IT_SERVICE", "PERSON", "DATA"] as const;
|
||||||
|
|
||||||
const STATUS_TONE = { ACTIVE: "ok", PLANNED: "info", RETIRED: "mut" } as const;
|
const STATUS_TONE = { ACTIVE: "ok", PLANNED: "info", RETIRED: "mut" } as const;
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,28 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getFormatter, getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
import { requireSession } from "@/server/auth";
|
import { requireSession } from "@/server/auth";
|
||||||
import { dbForTenant, prisma } from "@/server/db";
|
import { dbForTenant } from "@/server/db";
|
||||||
import { hasPermission, requirePermission } from "@/server/rbac";
|
import { hasPermission, requirePermission } from "@/server/rbac";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { CriticalityPill, KpiCard, PageHead, Pill } from "@/components/mockup-ui";
|
import { PageHead, Pill } from "@/components/mockup-ui";
|
||||||
import {
|
import {
|
||||||
SupplierCreateModal,
|
SupplierCreateModal,
|
||||||
SupplierDetailModal,
|
SupplierDetailModal,
|
||||||
SupplierEditModal,
|
SupplierEditModal,
|
||||||
|
type SupplierAssetDetail,
|
||||||
} from "@/components/supplier-modals";
|
} from "@/components/supplier-modals";
|
||||||
import { supplierRef, SUPPLIER_STATUS_TONE, isExpiring, isReviewDue } from "@/lib/supplier";
|
import {
|
||||||
|
supplierRef,
|
||||||
|
protectionLevel,
|
||||||
|
PROTECTION_LABEL,
|
||||||
|
buildReqContext,
|
||||||
|
conformity,
|
||||||
|
computedMaturity,
|
||||||
|
CONFORMITY_TONE,
|
||||||
|
CONFORMITY_LABEL,
|
||||||
|
TARGET_MATURITY,
|
||||||
|
} from "@/lib/supplier";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -21,16 +32,18 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
|
|
||||||
const SUPPLIER_INCLUDE = {
|
const DETAIL_INCLUDE = {
|
||||||
assessments: true,
|
supplierProfile: true,
|
||||||
contracts: true,
|
contracts: true,
|
||||||
ndas: true,
|
ndas: true,
|
||||||
evidence: true,
|
evidence: true,
|
||||||
responsibilities: true,
|
assessments: true,
|
||||||
subcontractors: true,
|
subcontractors: true,
|
||||||
decisions: true,
|
decisions: true,
|
||||||
controlMaturity: true,
|
maturity: true,
|
||||||
assetLinks: { include: { asset: { select: { id: true, name: true } } } },
|
riskAssets: { include: { risk: { select: { id: true, refNo: true, title: true, score: true } } } },
|
||||||
|
relationsFrom: { include: { relatedAsset: { select: { name: true, confidentiality: true, integrity: true, availability: true } } } },
|
||||||
|
relationsTo: { include: { asset: { select: { name: true, confidentiality: true, integrity: true, availability: true } } } },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export default async function SuppliersPage({
|
export default async function SuppliersPage({
|
||||||
@@ -41,37 +54,49 @@ export default async function SuppliersPage({
|
|||||||
const session = await requireSession();
|
const session = await requireSession();
|
||||||
requirePermission(session, "supplier:read");
|
requirePermission(session, "supplier:read");
|
||||||
const t = await getTranslations("suppliers");
|
const t = await getTranslations("suppliers");
|
||||||
const tStatus = await getTranslations("supplierStatus");
|
|
||||||
const tCrit = await getTranslations("criticality");
|
|
||||||
const tc = await getTranslations("common");
|
const tc = await getTranslations("common");
|
||||||
const fmt = await getFormatter();
|
|
||||||
|
|
||||||
const params = await searchParams;
|
const params = await searchParams;
|
||||||
const db = dbForTenant(session.user.tenantId);
|
const db = dbForTenant(session.user.tenantId);
|
||||||
const canWrite = hasPermission(session, "supplier:write");
|
const canWrite = hasPermission(session, "supplier:write");
|
||||||
|
|
||||||
const suppliers = await db.supplier.findMany({
|
const suppliers = await db.asset.findMany({
|
||||||
include: {
|
where: { type: "SUPPLIER", supplierProfile: { isNot: null } },
|
||||||
contracts: { select: { validTo: true } },
|
include: DETAIL_INCLUDE,
|
||||||
evidence: { select: { validTo: true } },
|
orderBy: { supplierProfile: { refNo: "asc" } },
|
||||||
_count: { select: { assessments: true, contracts: true } },
|
|
||||||
},
|
|
||||||
orderBy: { refNo: "asc" },
|
|
||||||
take: 300,
|
take: 300,
|
||||||
});
|
});
|
||||||
|
|
||||||
const total = suppliers.length;
|
const rows = suppliers.map((s) => {
|
||||||
const nis2 = suppliers.filter((s) => s.nis2Relevant).length;
|
const cias = [
|
||||||
const expiring = suppliers.filter(
|
Math.max(s.confidentiality, s.integrity, s.availability),
|
||||||
(s) =>
|
...s.relationsFrom.map((r) => Math.max(r.relatedAsset.confidentiality, r.relatedAsset.integrity, r.relatedAsset.availability)),
|
||||||
s.contracts.some((c) => isExpiring(c.validTo)) || s.evidence.some((e) => isExpiring(e.validTo))
|
...s.relationsTo.map((r) => Math.max(r.asset.confidentiality, r.asset.integrity, r.asset.availability)),
|
||||||
).length;
|
];
|
||||||
const reviewsDue = suppliers.filter((s) => isReviewDue(s.nextReview)).length;
|
const maxCia = Math.max(...cias);
|
||||||
|
const level = protectionLevel(maxCia);
|
||||||
|
const ctx = buildReqContext({
|
||||||
|
contracts: s.contracts,
|
||||||
|
ndas: s.ndas,
|
||||||
|
evidence: s.evidence,
|
||||||
|
assessments: s.assessments,
|
||||||
|
subcontractors: s.subcontractors,
|
||||||
|
nextReview: s.supplierProfile?.nextReview ?? null,
|
||||||
|
linkedRiskCount: s.riskAssets.length,
|
||||||
|
decisionCount: s.decisions.length,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
s,
|
||||||
|
level,
|
||||||
|
conf: conformity(level, ctx),
|
||||||
|
maturity: s.maturity?.isbValue ?? computedMaturity(level, ctx),
|
||||||
|
linked: [...s.relationsFrom.map((r) => r.relatedAsset.name), ...s.relationsTo.map((r) => r.asset.name)],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const controls = await prisma.supplierControl.findMany({ orderBy: { ref: "asc" } });
|
|
||||||
const modalId = params.edit && canWrite ? params.edit : params.detail;
|
const modalId = params.edit && canWrite ? params.edit : params.detail;
|
||||||
const modalSupplier = modalId
|
const modalSupplier = modalId
|
||||||
? await db.supplier.findUnique({ where: { id: modalId }, include: SUPPLIER_INCLUDE })
|
? ((await db.asset.findUnique({ where: { id: modalId }, include: DETAIL_INCLUDE })) as SupplierAssetDetail | null)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -89,45 +114,40 @@ export default async function SuppliersPage({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<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">
|
<div className="shadow-card mt-4 rounded-xl border bg-card">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>{t("ref")}</TableHead>
|
<TableHead>{t("ref")}</TableHead>
|
||||||
<TableHead>{t("name")}</TableHead>
|
<TableHead>{t("name")}</TableHead>
|
||||||
<TableHead>{t("sector")}</TableHead>
|
<TableHead>{t("linkedAssets")}</TableHead>
|
||||||
<TableHead>{t("criticality")}</TableHead>
|
<TableHead>{t("derivedLevel")}</TableHead>
|
||||||
<TableHead>NIS2</TableHead>
|
<TableHead>{t("maturity")}</TableHead>
|
||||||
<TableHead>{t("nextReview")}</TableHead>
|
<TableHead>{t("conformity")}</TableHead>
|
||||||
<TableHead>{t("status")}</TableHead>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{suppliers.length === 0 && (
|
{rows.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={7} className="py-8 text-center text-muted-foreground">{t("empty")}</TableCell>
|
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">{t("empty")}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
{suppliers.map((s) => (
|
{rows.map(({ s, level, conf, maturity, linked }) => (
|
||||||
<TableRow key={s.id}>
|
<TableRow key={s.id}>
|
||||||
<TableCell className="text-muted-foreground">{supplierRef(s.refNo)}</TableCell>
|
<TableCell className="text-muted-foreground">{supplierRef(s.supplierProfile!.refNo)}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Link href={`/suppliers?detail=${s.id}`} className="font-bold hover:underline">{s.name}</Link>
|
<Link href={`/suppliers?detail=${s.id}`} className="font-bold hover:underline">{s.name}</Link>
|
||||||
|
{s.supplierProfile?.sector && <div className="text-xs text-muted-foreground">{s.supplierProfile.sector}</div>}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-muted-foreground">{s.sector ?? tc("none")}</TableCell>
|
<TableCell className="max-w-52 truncate text-muted-foreground">{linked.join(", ") || tc("none")}</TableCell>
|
||||||
<TableCell><CriticalityPill level={s.criticality} label={tCrit(String(s.criticality))} /></TableCell>
|
<TableCell>
|
||||||
<TableCell>{s.nis2Relevant ? <Pill tone="info">NIS2</Pill> : <span className="text-muted-foreground">{tc("none")}</span>}</TableCell>
|
<Pill tone={level === 3 ? "risk" : level === 2 ? "warn" : "ok"}>Schutzbedarf {PROTECTION_LABEL[level]}</Pill>
|
||||||
<TableCell className={isReviewDue(s.nextReview) ? "text-[var(--warn)]" : "text-muted-foreground"}>
|
|
||||||
{s.nextReview ? fmt.dateTime(s.nextReview, { dateStyle: "medium" }) : tc("none")}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell><Pill tone={SUPPLIER_STATUS_TONE[s.status]}>{tStatus(s.status)}</Pill></TableCell>
|
<TableCell>
|
||||||
|
<span className="font-heading font-bold">{maturity.toFixed(1)}</span>
|
||||||
|
<span className="text-xs text-muted-foreground"> / {TARGET_MATURITY.toFixed(1)}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell><Pill tone={CONFORMITY_TONE[conf]}>{CONFORMITY_LABEL[conf]}</Pill></TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@@ -135,9 +155,9 @@ export default async function SuppliersPage({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{modalSupplier && params.edit && canWrite ? (
|
{modalSupplier && params.edit && canWrite ? (
|
||||||
<SupplierEditModal supplier={modalSupplier} controls={controls} />
|
<SupplierEditModal supplier={modalSupplier} />
|
||||||
) : modalSupplier ? (
|
) : modalSupplier ? (
|
||||||
<SupplierDetailModal supplier={modalSupplier} controls={controls} canWrite={canWrite} />
|
<SupplierDetailModal supplier={modalSupplier} canWrite={canWrite} />
|
||||||
) : params.new && canWrite ? (
|
) : params.new && canWrite ? (
|
||||||
<SupplierCreateModal />
|
<SupplierCreateModal />
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { SegmentedRating } from "@/components/segmented-rating";
|
import { SegmentedRating } from "@/components/segmented-rating";
|
||||||
|
|
||||||
const ASSET_TYPES = ["INFORMATION", "SYSTEM", "APPLICATION", "LOCATION", "SUPPLIER", "PERSON", "DATA"] as const;
|
const ASSET_TYPES = ["INFORMATION", "SYSTEM", "APPLICATION", "LOCATION", "SUPPLIER", "IT_SERVICE", "PERSON", "DATA"] as const;
|
||||||
const ASSET_STATUS = ["ACTIVE", "PLANNED", "RETIRED"] as const;
|
const ASSET_STATUS = ["ACTIVE", "PLANNED", "RETIRED"] as const;
|
||||||
|
|
||||||
/** Formular für Anlegen/Bearbeiten eines Assets (Server-Action wird übergeben). */
|
/** Formular für Anlegen/Bearbeiten eines Assets (Server-Action wird übergeben). */
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
AppWindow,
|
AppWindow,
|
||||||
MapPin,
|
MapPin,
|
||||||
Truck,
|
Truck,
|
||||||
|
Server,
|
||||||
Users,
|
Users,
|
||||||
FileText,
|
FileText,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
@@ -42,6 +43,7 @@ const KIND_ICON: Record<GraphNodeKind, typeof Boxes> = {
|
|||||||
APPLICATION: AppWindow,
|
APPLICATION: AppWindow,
|
||||||
LOCATION: MapPin,
|
LOCATION: MapPin,
|
||||||
SUPPLIER: Truck,
|
SUPPLIER: Truck,
|
||||||
|
IT_SERVICE: Server,
|
||||||
PERSON: Users,
|
PERSON: Users,
|
||||||
DATA: Database,
|
DATA: Database,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Check, X, AlertTriangle, ShieldCheck } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
PROTECTION_LABEL,
|
||||||
|
type ProtectionLevel,
|
||||||
|
type ReqContext,
|
||||||
|
type ReqTheme,
|
||||||
|
type Tier,
|
||||||
|
} from "@/lib/supplier";
|
||||||
|
|
||||||
|
type SerReq = { key: string; label: string; control: string; tier: Tier; theme: ReqTheme; met: boolean };
|
||||||
|
|
||||||
|
const TIER_MIN: Record<Tier, number> = { must: 1, should: 1, high: 2, veryhigh: 3 };
|
||||||
|
const THEME_LABEL: Record<ReqTheme, string> = {
|
||||||
|
contract: "Vertrag & Geheimhaltung",
|
||||||
|
assessment: "Bewertung & Nachweise",
|
||||||
|
transparency: "Transparenz (sehr hoher Schutzbedarf)",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SupplierRequirements({
|
||||||
|
reqs,
|
||||||
|
ctx,
|
||||||
|
actualLevel,
|
||||||
|
maxCia,
|
||||||
|
linkedAssets,
|
||||||
|
computedMaturity,
|
||||||
|
isbValue,
|
||||||
|
targetMaturity,
|
||||||
|
gateEvidenceHref,
|
||||||
|
gateDecisionHref,
|
||||||
|
createRiskAction,
|
||||||
|
}: {
|
||||||
|
reqs: SerReq[];
|
||||||
|
ctx: ReqContext;
|
||||||
|
actualLevel: ProtectionLevel;
|
||||||
|
maxCia: number;
|
||||||
|
linkedAssets: { name: string; c: number; i: number; a: number }[];
|
||||||
|
computedMaturity: number;
|
||||||
|
isbValue: number | null;
|
||||||
|
targetMaturity: number;
|
||||||
|
gateEvidenceHref: string;
|
||||||
|
gateDecisionHref: string;
|
||||||
|
createRiskAction: () => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [sim, setSim] = useState<ProtectionLevel>(actualLevel);
|
||||||
|
|
||||||
|
const status = (r: SerReq): "met" | "open" | "na" =>
|
||||||
|
sim < TIER_MIN[r.tier] ? "na" : r.met ? "met" : "open";
|
||||||
|
|
||||||
|
const gateActive = sim === 3 && !ctx.hasAudit;
|
||||||
|
const gateSatisfied = ctx.hasAudit || (ctx.hasManagementDecision && ctx.hasLinkedRisk);
|
||||||
|
|
||||||
|
const themes: ReqTheme[] = ["contract", "assessment", "transparency"];
|
||||||
|
|
||||||
|
const maturity = isbValue ?? computedMaturity;
|
||||||
|
const maturityOk = maturity >= targetMaturity;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Schutzbedarf-Banner mit Simulation */}
|
||||||
|
<div className="rounded-xl border border-[var(--panel-brd)] bg-[var(--surface-soft)] p-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-heading text-[14px] font-semibold">Schutzbedarf steuert die Anforderungen</p>
|
||||||
|
<p className="text-[12px] text-muted-foreground">
|
||||||
|
Verknüpfte Assets:{" "}
|
||||||
|
{linkedAssets.length
|
||||||
|
? linkedAssets.map((a) => `${a.name} (${a.c}/${a.i}/${a.a})`).join(" · ")
|
||||||
|
: "—"}{" "}
|
||||||
|
· abgeleitet: <b className="text-foreground">{PROTECTION_LABEL[actualLevel]}</b> (max C/I/A {maxCia})
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-[12px]">
|
||||||
|
<span className="text-muted-foreground">Simulieren:</span>
|
||||||
|
{([1, 2, 3] as ProtectionLevel[]).map((lvl) => (
|
||||||
|
<button
|
||||||
|
key={lvl}
|
||||||
|
onClick={() => setSim(lvl)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border px-2.5 py-1 font-heading font-semibold",
|
||||||
|
sim === lvl
|
||||||
|
? lvl === 3
|
||||||
|
? "border-transparent bg-[var(--risk)] text-white"
|
||||||
|
: "border-transparent bg-[var(--primary)] text-white"
|
||||||
|
: "border-[var(--panel-brd)] bg-[var(--elevated)] text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{PROTECTION_LABEL[lvl]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Flache Anforderungsliste, nach Themen gruppiert */}
|
||||||
|
<div className="rounded-xl border p-4">
|
||||||
|
<p className="font-heading text-[15px] font-semibold">Anforderungen</p>
|
||||||
|
<p className="mb-2 text-[12px] text-muted-foreground">
|
||||||
|
Nur die tatsächlich relevanten Anforderungen — mit Referenz und Herkunfts-Control.
|
||||||
|
</p>
|
||||||
|
{themes.map((theme) => {
|
||||||
|
const rows = reqs.filter((r) => r.theme === theme);
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div key={theme} className="mt-3">
|
||||||
|
<p className="text-[10.5px] font-bold tracking-[.05em] text-muted-foreground uppercase">
|
||||||
|
{THEME_LABEL[theme]}
|
||||||
|
</p>
|
||||||
|
<ul className="mt-1">
|
||||||
|
{rows.map((r) => {
|
||||||
|
const st = status(r);
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={r.key}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2.5 border-b border-[var(--panel-brd)] py-2 text-[13px] last:border-0",
|
||||||
|
st === "na" && "opacity-40"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{st === "met" ? (
|
||||||
|
<Check className="size-4 shrink-0 text-[var(--ok)]" />
|
||||||
|
) : st === "open" ? (
|
||||||
|
<X className="size-4 shrink-0 text-[var(--risk)]" />
|
||||||
|
) : (
|
||||||
|
<span className="size-4 shrink-0 rounded-full border border-dashed border-muted-foreground/40" />
|
||||||
|
)}
|
||||||
|
<span className="min-w-0 flex-1">{r.label}</span>
|
||||||
|
{st === "open" && (
|
||||||
|
<Link
|
||||||
|
href={r.theme === "assessment" ? gateEvidenceHref : gateDecisionHref}
|
||||||
|
className="text-[11.5px] font-semibold text-[var(--info)] hover:underline"
|
||||||
|
>
|
||||||
|
+ verknüpfen
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<span className="text-[10.5px] font-bold text-muted-foreground/70">{r.control}</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Gate-Panel */}
|
||||||
|
{gateActive && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mt-4 rounded-xl border p-4",
|
||||||
|
gateSatisfied
|
||||||
|
? "border-[rgba(57,192,127,0.4)] bg-[rgba(57,192,127,0.1)]"
|
||||||
|
: "border-[rgba(255,107,107,0.4)] bg-[rgba(255,107,107,0.1)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p className="flex items-center gap-2 text-[13px] font-semibold text-[var(--risk)]">
|
||||||
|
<AlertTriangle className="size-4" /> Sehr hoher Schutzbedarf — Kompensation erforderlich
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-[12px] text-muted-foreground">
|
||||||
|
Kein gültiges Third-Party-Audit / TISAX-Label. Nachweis über eine der Optionen:
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="rounded-lg border border-[var(--panel-brd)] p-3">
|
||||||
|
<p className="text-[12px] font-semibold">Option A · Audit-Nachweis</p>
|
||||||
|
<Link
|
||||||
|
href={gateEvidenceHref}
|
||||||
|
className="mt-2 inline-block rounded-lg bg-[var(--primary)] px-2.5 py-1.5 text-[12px] font-semibold text-white hover:opacity-90"
|
||||||
|
>
|
||||||
|
TISAX-Label / Audit erfassen
|
||||||
|
</Link>
|
||||||
|
<p className="mt-2 text-[11px] text-muted-foreground">oder selbst durchgeführtes Lieferanten-Audit</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-[var(--panel-brd)] p-3">
|
||||||
|
<p className="text-[12px] font-semibold">Option B · Kompensation (beides nötig)</p>
|
||||||
|
<div className="mt-2 flex flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-between gap-2 text-[12px]">
|
||||||
|
<span className={ctx.hasManagementDecision ? "text-[var(--ok)]" : ""}>Managemententscheidung</span>
|
||||||
|
<Link href={gateDecisionHref} className="rounded-md border border-[var(--panel-brd)] px-2 py-1 text-[11.5px] font-semibold hover:bg-[var(--accent)]">
|
||||||
|
Dokumentieren
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-2 text-[12px]">
|
||||||
|
<span className={ctx.hasLinkedRisk ? "text-[var(--ok)]" : ""}>Risiko im Risikomodul</span>
|
||||||
|
<form action={createRiskAction}>
|
||||||
|
<button type="submit" className="rounded-md border border-[var(--panel-brd)] px-2 py-1 text-[11.5px] font-semibold hover:bg-[var(--accent)]">
|
||||||
|
Risiko anlegen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reifegrad-/Konformitätskarte */}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ShieldCheck className={cn("size-6", maturityOk ? "text-[var(--ok)]" : "text-[var(--warn)]")} />
|
||||||
|
<div>
|
||||||
|
<p className="text-[12px] text-muted-foreground">Reifegrad</p>
|
||||||
|
<p className="font-heading text-2xl font-bold leading-none">
|
||||||
|
{maturity.toFixed(1)} <span className="text-[13px] font-normal text-muted-foreground">/ {targetMaturity.toFixed(1)}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11.5px] text-muted-foreground">
|
||||||
|
{isbValue != null ? "ISB-freigegeben" : `berechnet: ${computedMaturity.toFixed(1)}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+186
-408
@@ -1,19 +1,18 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getFormatter, getTranslations } from "next-intl/server";
|
import { getFormatter, getTranslations } from "next-intl/server";
|
||||||
import { Pencil, Plus, Trash2, X, AlertTriangle } from "lucide-react";
|
import { Pencil, Plus, Trash2, X } from "lucide-react";
|
||||||
import type { Prisma, SupplierControl } from "@prisma/client";
|
import type { Prisma } from "@prisma/client";
|
||||||
import {
|
import {
|
||||||
addAssessment,
|
addAssessment,
|
||||||
addContract,
|
addContract,
|
||||||
addDecision,
|
addDecision,
|
||||||
addEvidence,
|
addEvidence,
|
||||||
addNda,
|
addNda,
|
||||||
addResponsibility,
|
approveMaturity,
|
||||||
addSubcontractor,
|
createGateRisk,
|
||||||
createSupplier,
|
createSupplier,
|
||||||
deleteChild,
|
deleteChild,
|
||||||
deleteSupplier,
|
deleteSupplier,
|
||||||
saveMaturity,
|
|
||||||
updateSupplier,
|
updateSupplier,
|
||||||
} from "@/server/actions/suppliers";
|
} from "@/server/actions/suppliers";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -22,59 +21,91 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Modal } from "@/components/modal";
|
import { Modal } from "@/components/modal";
|
||||||
import { SegmentedRating } from "@/components/segmented-rating";
|
import { SegmentedRating } from "@/components/segmented-rating";
|
||||||
import { CiaBadge, CriticalityPill, Pill } from "@/components/mockup-ui";
|
import { Pill } from "@/components/mockup-ui";
|
||||||
import { supplierRef, SUPPLIER_STATUS_TONE, isExpired, isExpiring, needsManagementDecision } from "@/lib/supplier";
|
import { SupplierRequirements } from "@/components/supplier-cockpit";
|
||||||
|
import {
|
||||||
|
supplierRef,
|
||||||
|
SUPPLIER_LIFECYCLE_TONE,
|
||||||
|
protectionLevel,
|
||||||
|
PROTECTION_LABEL,
|
||||||
|
buildReqContext,
|
||||||
|
REQUIREMENTS,
|
||||||
|
conformity,
|
||||||
|
computedMaturity,
|
||||||
|
CONFORMITY_TONE,
|
||||||
|
CONFORMITY_LABEL,
|
||||||
|
TARGET_MATURITY,
|
||||||
|
isExpired,
|
||||||
|
isExpiring,
|
||||||
|
} from "@/lib/supplier";
|
||||||
|
|
||||||
export type SupplierWithDetail = Prisma.SupplierGetPayload<{
|
export type SupplierAssetDetail = Prisma.AssetGetPayload<{
|
||||||
include: {
|
include: {
|
||||||
assessments: true;
|
supplierProfile: true;
|
||||||
contracts: true;
|
contracts: true;
|
||||||
ndas: true;
|
ndas: true;
|
||||||
evidence: true;
|
evidence: true;
|
||||||
responsibilities: true;
|
assessments: true;
|
||||||
subcontractors: true;
|
subcontractors: true;
|
||||||
decisions: true;
|
decisions: true;
|
||||||
controlMaturity: true;
|
maturity: true;
|
||||||
assetLinks: { include: { asset: { select: { id: true; name: true } } } };
|
riskAssets: { include: { risk: { select: { id: true; refNo: true; title: true; score: true } } } };
|
||||||
|
relationsFrom: { include: { relatedAsset: { select: { name: true; confidentiality: true; integrity: true; availability: true } } } };
|
||||||
|
relationsTo: { include: { asset: { select: { name: true; confidentiality: true; integrity: true; availability: 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";
|
const inputCls = "h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm";
|
||||||
|
|
||||||
/* ─────────────────────────── Detail (read-only) ─────────────────────────── */
|
function contextOf(s: SupplierAssetDetail) {
|
||||||
|
const linkedAssets = [
|
||||||
|
{ name: s.name, c: s.confidentiality, i: s.integrity, a: s.availability },
|
||||||
|
...s.relationsFrom.map((r) => ({ name: r.relatedAsset.name, c: r.relatedAsset.confidentiality, i: r.relatedAsset.integrity, a: r.relatedAsset.availability })),
|
||||||
|
...s.relationsTo.map((r) => ({ name: r.asset.name, c: r.asset.confidentiality, i: r.asset.integrity, a: r.asset.availability })),
|
||||||
|
];
|
||||||
|
const maxCia = Math.max(...linkedAssets.map((a) => Math.max(a.c, a.i, a.a)));
|
||||||
|
const level = protectionLevel(maxCia);
|
||||||
|
const ctx = buildReqContext({
|
||||||
|
contracts: s.contracts,
|
||||||
|
ndas: s.ndas,
|
||||||
|
evidence: s.evidence,
|
||||||
|
assessments: s.assessments,
|
||||||
|
subcontractors: s.subcontractors,
|
||||||
|
nextReview: s.supplierProfile?.nextReview ?? null,
|
||||||
|
linkedRiskCount: s.riskAssets.length,
|
||||||
|
decisionCount: s.decisions.length,
|
||||||
|
});
|
||||||
|
return { linkedAssets: linkedAssets.slice(1), maxCia, level, ctx };
|
||||||
|
}
|
||||||
|
|
||||||
export async function SupplierDetailModal({
|
/* ─────────────────────── Cockpit (Detail) ─────────────────────── */
|
||||||
supplier,
|
|
||||||
controls,
|
export async function SupplierDetailModal({ supplier, canWrite }: { supplier: SupplierAssetDetail; canWrite: boolean }) {
|
||||||
canWrite,
|
|
||||||
}: {
|
|
||||||
supplier: SupplierWithDetail;
|
|
||||||
controls: SupplierControl[];
|
|
||||||
canWrite: boolean;
|
|
||||||
}) {
|
|
||||||
const t = await getTranslations("suppliers");
|
const t = await getTranslations("suppliers");
|
||||||
const tStatus = await getTranslations("supplierStatus");
|
const tLife = await getTranslations("supplierStatus");
|
||||||
const tCrit = await getTranslations("criticality");
|
|
||||||
const tAType = await getTranslations("assessmentType");
|
|
||||||
const tEKind = await getTranslations("evidenceKind");
|
const tEKind = await getTranslations("evidenceKind");
|
||||||
const tParty = await getTranslations("responsibleParty");
|
|
||||||
const tc = await getTranslations("common");
|
const tc = await getTranslations("common");
|
||||||
const fmt = await getFormatter();
|
const fmt = await getFormatter();
|
||||||
const date = (d: Date | null) => (d ? fmt.dateTime(d, { dateStyle: "medium" }) : tc("none"));
|
const date = (d: Date | null) => (d ? fmt.dateTime(d, { dateStyle: "medium" }) : tc("none"));
|
||||||
|
|
||||||
const decisionNeeded = needsManagementDecision(supplier.evidence);
|
const p = supplier.supplierProfile!;
|
||||||
const matById = new Map(supplier.controlMaturity.map((m) => [m.controlId, m.maturity]));
|
const { linkedAssets, maxCia, level, ctx } = contextOf(supplier);
|
||||||
|
const conf = conformity(level, ctx);
|
||||||
|
const computed = computedMaturity(level, ctx);
|
||||||
|
const reqs = REQUIREMENTS.map((r) => ({ key: r.key, label: r.label, control: r.control, tier: r.tier, theme: r.theme, met: r.met(ctx) }));
|
||||||
|
|
||||||
|
const gateEvidenceHref = `/suppliers?edit=${supplier.id}`;
|
||||||
|
const gateDecisionHref = `/suppliers?edit=${supplier.id}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title={`${supplierRef(supplier.refNo)} · ${supplier.name}`}
|
title={`${supplierRef(p.refNo)} · ${supplier.name}`}
|
||||||
sub={t("detailSub")}
|
sub={p.serviceDesc ?? t("detailSub")}
|
||||||
headerExtra={
|
headerExtra={
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
{supplier.nis2Relevant && <Pill tone="info">NIS2</Pill>}
|
{p.nis2Relevant && <Pill tone="info">NIS2</Pill>}
|
||||||
<CriticalityPill level={supplier.criticality} label={tCrit(String(supplier.criticality))} />
|
<Pill tone={level === 3 ? "risk" : level === 2 ? "warn" : "ok"}>Schutzbedarf {PROTECTION_LABEL[level]}</Pill>
|
||||||
<Pill tone={SUPPLIER_STATUS_TONE[supplier.status]}>{tStatus(supplier.status)}</Pill>
|
<Pill tone={CONFORMITY_TONE[conf]}>{CONFORMITY_LABEL[conf]}</Pill>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
closeHref="/suppliers"
|
closeHref="/suppliers"
|
||||||
@@ -86,234 +117,112 @@ export async function SupplierDetailModal({
|
|||||||
<Pencil className="size-4" /> {tc("edit")}
|
<Pencil className="size-4" /> {tc("edit")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button nativeButton={false} render={<Link href="/suppliers" />}>
|
<Button nativeButton={false} render={<Link href="/suppliers" />}>{t("close")}</Button>
|
||||||
{t("close")}
|
|
||||||
</Button>
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="space-y-5 p-5">
|
<div className="space-y-5 p-5">
|
||||||
{/* Stammdaten + Assets */}
|
<SupplierRequirements
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
reqs={reqs}
|
||||||
<div className="rounded-xl border border-l-[3px] border-l-[var(--primary)] bg-[var(--surface-soft)] p-4 text-[12.5px]">
|
ctx={ctx}
|
||||||
<dl className="grid grid-cols-[9rem_1fr] gap-1.5">
|
actualLevel={level}
|
||||||
<dt className="text-muted-foreground">{t("sector")}</dt>
|
maxCia={maxCia}
|
||||||
<dd>{supplier.sector ?? tc("none")}</dd>
|
linkedAssets={linkedAssets}
|
||||||
<dt className="text-muted-foreground">{t("services")}</dt>
|
computedMaturity={computed}
|
||||||
<dd>{supplier.services ?? tc("none")}</dd>
|
isbValue={supplier.maturity?.isbValue ?? null}
|
||||||
<dt className="text-muted-foreground">{t("contact")}</dt>
|
targetMaturity={TARGET_MATURITY}
|
||||||
<dd>{supplier.contact ?? tc("none")}</dd>
|
gateEvidenceHref={gateEvidenceHref}
|
||||||
<dt className="text-muted-foreground">{t("dataCategories")}</dt>
|
gateDecisionHref={gateDecisionHref}
|
||||||
<dd>{supplier.dataCategories.join(", ") || tc("none")}</dd>
|
createRiskAction={createGateRisk.bind(null, supplier.id)}
|
||||||
<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>
|
{/* Risiken */}
|
||||||
<dd>{date(supplier.nextReview)}</dd>
|
<section>
|
||||||
</dl>
|
<p className="text-sm font-semibold">{t("risks")}</p>
|
||||||
</div>
|
{supplier.riskAssets.length === 0 && <p className="mt-1 text-sm text-muted-foreground">{tc("none")}</p>}
|
||||||
<div>
|
<ul className="mt-1.5 space-y-1.5 text-sm">
|
||||||
<p className="text-sm font-semibold">{t("assets")}</p>
|
{supplier.riskAssets.map((ra) => (
|
||||||
{supplier.assetLinks.length === 0 && <p className="mt-1 text-sm text-muted-foreground">{tc("none")}</p>}
|
<li key={ra.id} className="flex items-center gap-2">
|
||||||
<ul className="mt-1.5 space-y-1 text-sm">
|
<Link href={`/risks?detail=${ra.risk.id}`} className="font-bold hover:underline">R-{String(ra.risk.refNo).padStart(3, "0")}</Link>
|
||||||
{supplier.assetLinks.map((l) => (
|
<Link href={`/risks?detail=${ra.risk.id}`} className="hover:underline">{ra.risk.title}</Link>
|
||||||
<li key={l.id}>
|
|
||||||
<Link href={`/assets?detail=${l.asset.id}`} className="hover:underline">{l.asset.name}</Link>
|
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</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>
|
</section>
|
||||||
|
|
||||||
{/* Nachweise */}
|
{/* Verträge & NDA & Nachweise (read) */}
|
||||||
<ListSection title={t("evidence")}>
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
{supplier.evidence.length === 0 ? (
|
<section>
|
||||||
<Empty t={tc("none")} />
|
<p className="text-sm font-semibold">{t("contracts")} & {t("ndas")}</p>
|
||||||
) : (
|
<ul className="mt-1.5 space-y-1.5 text-sm">
|
||||||
supplier.evidence.map((e) => (
|
{supplier.contracts.map((c) => (
|
||||||
|
<li key={c.id} className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="font-medium">{c.reference ?? c.type}</span>
|
||||||
|
{c.avDpa && <Pill tone="ok">AV/DPA</Pill>}
|
||||||
|
{c.validTo && <span className={isExpiring(c.validTo) ? "text-[var(--warn)]" : "text-muted-foreground"}>{t("validTo")}: {date(c.validTo)}</span>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{supplier.ndas.map((n) => (
|
||||||
|
<li key={n.id} className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="font-medium">NDA: {n.subject ?? n.parties ?? ""}</span>
|
||||||
|
{n.validTo && <span className={isExpiring(n.validTo) ? "text-[var(--warn)]" : "text-muted-foreground"}>{t("validTo")}: {date(n.validTo)}</span>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{supplier.contracts.length + supplier.ndas.length === 0 && <li className="text-muted-foreground">{tc("none")}</li>}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<p className="text-sm font-semibold">{t("evidence")}</p>
|
||||||
|
<ul className="mt-1.5 space-y-1.5 text-sm">
|
||||||
|
{supplier.evidence.map((e) => (
|
||||||
<li key={e.id} className="flex flex-wrap items-center gap-2">
|
<li key={e.id} className="flex flex-wrap items-center gap-2">
|
||||||
<Pill tone="violet">{tEKind(e.kind)}</Pill>
|
<Pill tone="violet">{tEKind(e.kind)}</Pill>
|
||||||
<span>{e.name ?? tEKind(e.kind)}</span>
|
<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.adequacyChecked && <Pill tone="ok">{t("adequacy")}</Pill>}
|
||||||
{e.validTo && (
|
{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>}
|
||||||
<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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ListSection>
|
{supplier.evidence.length === 0 && <li className="text-muted-foreground">{tc("none")}</li>}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ISB-Freigabe */}
|
||||||
|
{canWrite && (
|
||||||
|
<section className="rounded-xl border border-[var(--band-brd)] bg-[var(--band)] p-4">
|
||||||
|
<p className="text-sm font-semibold">{t("isbApproval")}</p>
|
||||||
|
<form action={approveMaturity.bind(null, supplier.id, computed)} className="mt-2 flex flex-wrap items-end gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="isbValue">{t("isbValue")} (0–3)</Label>
|
||||||
|
<Input id="isbValue" name="isbValue" type="number" min={0} max={3} step={0.1} defaultValue={supplier.maturity?.isbValue ?? computed} className="mt-1 w-28" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor="justification">{t("justification")}</Label>
|
||||||
|
<Input id="justification" name="justification" defaultValue={supplier.maturity?.isbJustification ?? ""} className="mt-1" />
|
||||||
|
</div>
|
||||||
|
<Button type="submit">{t("approve")}</Button>
|
||||||
|
</form>
|
||||||
|
<p className="mt-1.5 text-[11px] text-muted-foreground">{t("approvalNote")}</p>
|
||||||
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Entscheidungen */}
|
{/* Lifecycle + Datenkategorien */}
|
||||||
{supplier.decisions.length > 0 && (
|
<div className="text-[12px] text-muted-foreground">
|
||||||
<ListSection title={t("decision")}>
|
{t("status")}: <Pill tone={SUPPLIER_LIFECYCLE_TONE[p.lifecycle]}>{tLife(p.lifecycle)}</Pill>
|
||||||
{supplier.decisions.map((d) => (
|
{p.dataCategories.length > 0 && ` · ${t("dataCategories")}: ${p.dataCategories.join(", ")}`}
|
||||||
<li key={d.id}>
|
</div>
|
||||||
<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>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ListSection({ title, children }: { title: string; children: React.ReactNode }) {
|
/* ─────────────────────── Stammdaten-Formular ─────────────────────── */
|
||||||
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?: SupplierAssetDetail; formId?: string }) {
|
||||||
|
|
||||||
async function SupplierFields({ supplier, formId }: { supplier?: SupplierWithDetail; formId?: string }) {
|
|
||||||
const t = await getTranslations("suppliers");
|
const t = await getTranslations("suppliers");
|
||||||
const tStatus = await getTranslations("supplierStatus");
|
const tLife = await getTranslations("supplierStatus");
|
||||||
const f = formId ? { form: formId } : {};
|
const f = formId ? { form: formId } : {};
|
||||||
|
const p = supplier?.supplierProfile;
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
@@ -322,33 +231,27 @@ async function SupplierFields({ supplier, formId }: { supplier?: SupplierWithDet
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="sector">{t("sector")}</Label>
|
<Label htmlFor="sector">{t("sector")}</Label>
|
||||||
<Input id="sector" name="sector" defaultValue={supplier?.sector ?? ""} className="mt-1" {...f} />
|
<Input id="sector" name="sector" defaultValue={p?.sector ?? ""} className="mt-1" {...f} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="contact">{t("contact")}</Label>
|
<Label htmlFor="contact">{t("contact")}</Label>
|
||||||
<Input id="contact" name="contact" defaultValue={supplier?.contact ?? ""} className="mt-1" {...f} />
|
<Input id="contact" name="contact" defaultValue={p?.contact ?? ""} className="mt-1" {...f} />
|
||||||
</div>
|
</div>
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<Label htmlFor="services">{t("services")}</Label>
|
<Label htmlFor="serviceDesc">{t("services")}</Label>
|
||||||
<Textarea id="services" name="services" rows={2} defaultValue={supplier?.services ?? ""} className="mt-1" {...f} />
|
<Textarea id="serviceDesc" name="serviceDesc" rows={2} defaultValue={p?.serviceDesc ?? ""} className="mt-1" {...f} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="status">{t("status")}</Label>
|
<Label htmlFor="lifecycle">{t("status")}</Label>
|
||||||
<select id="status" name="status" defaultValue={supplier?.status ?? "ACTIVE"} className={`${inputCls} mt-1`} {...f}>
|
<select id="lifecycle" name="lifecycle" defaultValue={p?.lifecycle ?? "ACTIVE"} className={`${inputCls} mt-1`} {...f}>
|
||||||
{STATUSES.map((v) => (
|
{(["ACTIVE", "ONBOARDING", "UNDER_REVIEW", "OFFBOARDED"] as const).map((v) => (
|
||||||
<option key={v} value={v}>{tStatus(v)}</option>
|
<option key={v} value={v}>{tLife(v)}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="criticality">{t("criticality")}</Label>
|
<Label>{t("criticality")}</Label>
|
||||||
<div className="mt-1">
|
<div className="mt-1"><SegmentedRating name="criticality" defaultValue={p?.criticality ?? 1} form={formId} /></div>
|
||||||
<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>
|
||||||
<div>
|
<div>
|
||||||
<Label>{t("protection")} (C/I/A)</Label>
|
<Label>{t("protection")} (C/I/A)</Label>
|
||||||
@@ -360,14 +263,18 @@ async function SupplierFields({ supplier, formId }: { supplier?: SupplierWithDet
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="nextReview">{t("nextReview")}</Label>
|
<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} />
|
<Input id="nextReview" name="nextReview" type="date" defaultValue={p?.nextReview ? p.nextReview.toISOString().slice(0, 10) : ""} className="mt-1" {...f} />
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<Label htmlFor="dataCategories">{t("dataCategories")}</Label>
|
||||||
|
<Input id="dataCategories" name="dataCategories" defaultValue={p?.dataCategories.join(", ") ?? ""} className="mt-1" {...f} />
|
||||||
</div>
|
</div>
|
||||||
<label className="flex items-center gap-2 text-sm md:col-span-2">
|
<label className="flex items-center gap-2 text-sm md:col-span-2">
|
||||||
<input type="checkbox" name="nis2Relevant" defaultChecked={supplier?.nis2Relevant} {...f} /> {t("nis2")}
|
<input type="checkbox" name="nis2Relevant" defaultChecked={p?.nis2Relevant} {...f} /> {t("nis2")}
|
||||||
</label>
|
</label>
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<Label htmlFor="notes">{t("notes")}</Label>
|
<Label htmlFor="notes">{t("notes")}</Label>
|
||||||
<Textarea id="notes" name="notes" rows={2} defaultValue={supplier?.notes ?? ""} className="mt-1" {...f} />
|
<Textarea id="notes" name="notes" rows={2} defaultValue={p?.notes ?? ""} className="mt-1" {...f} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -389,40 +296,27 @@ export async function SupplierCreateModal() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─────────────────────────── Bearbeiten ─────────────────────────── */
|
/* ─────────────────────── Bearbeiten ─────────────────────── */
|
||||||
|
|
||||||
export async function SupplierEditModal({
|
export async function SupplierEditModal({ supplier }: { supplier: SupplierAssetDetail }) {
|
||||||
supplier,
|
|
||||||
controls,
|
|
||||||
}: {
|
|
||||||
supplier: SupplierWithDetail;
|
|
||||||
controls: SupplierControl[];
|
|
||||||
}) {
|
|
||||||
const t = await getTranslations("suppliers");
|
const t = await getTranslations("suppliers");
|
||||||
const tc = await getTranslations("common");
|
const tc = await getTranslations("common");
|
||||||
const tEKind = await getTranslations("evidenceKind");
|
const tEKind = await getTranslations("evidenceKind");
|
||||||
const tAType = await getTranslations("assessmentType");
|
const tAType = await getTranslations("assessmentType");
|
||||||
const tParty = await getTranslations("responsibleParty");
|
|
||||||
const FORM = "supplier-edit";
|
const FORM = "supplier-edit";
|
||||||
const matById = new Map(supplier.controlMaturity.map((m) => [m.controlId, m.maturity]));
|
|
||||||
|
|
||||||
const disc =
|
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";
|
"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 (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title={t("editTitle")}
|
title={t("editTitle")}
|
||||||
sub={`${supplierRef(supplier.refNo)} · ${supplier.name}`}
|
sub={supplier.name}
|
||||||
headerExtra={
|
headerExtra={
|
||||||
<details className="relative">
|
<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">
|
<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>
|
||||||
<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">
|
<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)}>
|
<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">
|
<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>
|
||||||
<Trash2 className="size-4" /> {tc("delete")}
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
@@ -440,41 +334,9 @@ export async function SupplierEditModal({
|
|||||||
<div className="space-y-6 p-5">
|
<div className="space-y-6 p-5">
|
||||||
<SupplierFields supplier={supplier} formId={FORM} />
|
<SupplierFields supplier={supplier} formId={FORM} />
|
||||||
|
|
||||||
{/* Reifegrade je Control */}
|
<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")} disc={disc}>
|
||||||
<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">
|
<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}>
|
<select name="kind" className={inputCls}>{(["TISAX_LABEL", "AUDIT_REPORT", "CERTIFICATE", "ATTESTATION", "SELF_ASSESSMENT"] as const).map((k) => <option key={k} value={k}>{tEKind(k)}</option>)}</select>
|
||||||
{(["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="name" placeholder={t("name")} />
|
||||||
<Input name="protectsCia" placeholder={t("protectsCia")} defaultValue="C,I,A" />
|
<Input name="protectsCia" placeholder={t("protectsCia")} defaultValue="C,I,A" />
|
||||||
<Input name="validTo" type="date" />
|
<Input name="validTo" type="date" />
|
||||||
@@ -483,119 +345,44 @@ export async function SupplierEditModal({
|
|||||||
</form>
|
</form>
|
||||||
</ChildSection>
|
</ChildSection>
|
||||||
|
|
||||||
{/* Bewertungen */}
|
<ChildSection title={t("contracts")} items={supplier.contracts.map((c) => ({ id: c.id, label: `${c.reference ?? c.type}${c.avDpa ? " · AV" : ""}` }))} kind="contract" addLabel={t("addContract")} disc={disc}>
|
||||||
<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">
|
<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="reference" placeholder={t("reference")} />
|
||||||
<Input name="type" placeholder={t("type")} defaultValue="av_dpa" />
|
|
||||||
<div className="grid grid-cols-2 gap-1 text-[12px]">
|
<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="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="securityClauses" defaultChecked /> {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="flowdown" /> {t("flowdown")}</label>
|
||||||
<label className="flex items-center gap-1.5"><input type="checkbox" name="customerTransparency" /> {t("customerTransparency")}</label>
|
<label className="flex items-center gap-1.5"><input type="checkbox" name="customerRequirementsPassed" /> {t("customerReq")}</label>
|
||||||
</div>
|
</div>
|
||||||
<Input name="validFrom" type="date" />
|
|
||||||
<Input name="validTo" type="date" />
|
<Input name="validTo" type="date" />
|
||||||
<Button type="submit" variant="secondary" size="sm">{tc("add")}</Button>
|
<Button type="submit" variant="secondary" size="sm">{tc("add")}</Button>
|
||||||
</form>
|
</form>
|
||||||
</ChildSection>
|
</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")} disc={disc}>
|
||||||
<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">
|
<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="parties" placeholder={t("parties")} />
|
||||||
<Input name="subject" placeholder={t("subject")} />
|
<Input name="subject" placeholder={t("subject")} />
|
||||||
<Input name="infoScope" placeholder={t("infoScope")} />
|
|
||||||
<Input name="validFrom" type="date" />
|
|
||||||
<Input name="validTo" type="date" />
|
<Input name="validTo" type="date" />
|
||||||
<Input name="extensionStatus" placeholder={t("extensionStatus")} />
|
<Input name="extensionStatus" placeholder={t("extensionStatus")} />
|
||||||
<Button type="submit" variant="secondary" size="sm">{tc("add")}</Button>
|
<Button type="submit" variant="secondary" size="sm">{tc("add")}</Button>
|
||||||
</form>
|
</form>
|
||||||
</ChildSection>
|
</ChildSection>
|
||||||
|
|
||||||
{/* RACI */}
|
<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")} disc={disc}>
|
||||||
<ChildSection
|
<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">
|
||||||
title={t("raci")}
|
<select name="type" className={inputCls}>{(["SELF_ASSESSMENT", "QUESTIONNAIRE", "AUDIT"] as const).map((k) => <option key={k} value={k}>{tAType(k)}</option>)}</select>
|
||||||
items={supplier.responsibilities.map((r) => ({ id: r.id, label: `${r.itService} · ${tParty(r.responsibleParty)}` }))}
|
<Input name="score" type="number" min={0} max={100} placeholder={t("score")} />
|
||||||
kind="responsibility"
|
<Input name="nextReview" type="date" placeholder={t("nextReview")} />
|
||||||
addLabel={t("addRaci")}
|
<Input name="result" placeholder={t("result")} />
|
||||||
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>
|
<Button type="submit" variant="secondary" size="sm">{tc("add")}</Button>
|
||||||
</form>
|
</form>
|
||||||
</ChildSection>
|
</ChildSection>
|
||||||
|
|
||||||
{/* Subunternehmer */}
|
<ChildSection title={t("decision")} items={supplier.decisions.map((d) => ({ id: d.id, label: d.decision }))} kind="decision" addLabel={t("addDecision")} disc={disc}>
|
||||||
<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">
|
<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="reasonNoAudit" required rows={2} placeholder={t("reasonNoAudit")} />
|
||||||
<Textarea name="decision" required rows={2} placeholder={t("decisionText")} />
|
<Textarea name="decision" required rows={2} placeholder={t("decisionText")} />
|
||||||
<Input name="decidedBy" placeholder={t("decidedBy")} />
|
|
||||||
<Input name="recordRef" placeholder={t("recordRef")} />
|
<Input name="recordRef" placeholder={t("recordRef")} />
|
||||||
<Button type="submit" variant="secondary" size="sm">{tc("add")}</Button>
|
<Button type="submit" variant="secondary" size="sm">{tc("add")}</Button>
|
||||||
</form>
|
</form>
|
||||||
@@ -606,18 +393,13 @@ export async function SupplierEditModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function ChildSection({
|
async function ChildSection({
|
||||||
title,
|
title, items, kind, addLabel, disc, children,
|
||||||
items,
|
|
||||||
kind,
|
|
||||||
addLabel,
|
|
||||||
discClass,
|
|
||||||
children,
|
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
items: { id: string; label: string }[];
|
items: { id: string; label: string }[];
|
||||||
kind: "assessment" | "contract" | "nda" | "evidence" | "responsibility" | "subcontractor" | "decision";
|
kind: "contract" | "nda" | "evidence" | "assessment" | "decision" | "subcontractor";
|
||||||
addLabel: string;
|
addLabel: string;
|
||||||
discClass: string;
|
disc: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const tc = await getTranslations("common");
|
const tc = await getTranslations("common");
|
||||||
@@ -626,9 +408,7 @@ async function ChildSection({
|
|||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<p className="font-heading text-[15px] font-semibold">{title}</p>
|
<p className="font-heading text-[15px] font-semibold">{title}</p>
|
||||||
<details className="relative">
|
<details className="relative">
|
||||||
<summary className={discClass}>
|
<summary className={disc}><span className="inline-flex items-center gap-1.5"><Plus className="size-3.5" /> {addLabel}</span></summary>
|
||||||
<span className="inline-flex items-center gap-1.5"><Plus className="size-3.5" /> {addLabel}</span>
|
|
||||||
</summary>
|
|
||||||
{children}
|
{children}
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
@@ -638,9 +418,7 @@ async function ChildSection({
|
|||||||
<li key={it.id} className="flex items-center gap-2">
|
<li key={it.id} className="flex items-center gap-2">
|
||||||
<span className="min-w-0 flex-1 truncate">{it.label}</span>
|
<span className="min-w-0 flex-1 truncate">{it.label}</span>
|
||||||
<form action={deleteChild.bind(null, kind, it.id)}>
|
<form action={deleteChild.bind(null, kind, it.id)}>
|
||||||
<button type="submit" title={tc("remove")} className="text-muted-foreground hover:text-destructive">
|
<button type="submit" title={tc("remove")} className="text-muted-foreground hover:text-destructive"><X className="size-3.5" /></button>
|
||||||
<X className="size-3.5" />
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
+156
-23
@@ -1,8 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Anforderungs-Engine für Lieferanten (VDA-ISA 2027 Kap. 6, NIS2 Art. 21(2)(d)).
|
||||||
|
* Schutzbedarf → Anforderungsstufe; flacher Anforderungskatalog mit
|
||||||
|
* Status-Ableitung aus den hinterlegten Objekten; Gate-Logik & Konformität.
|
||||||
|
*/
|
||||||
|
|
||||||
export function supplierRef(refNo: number): string {
|
export function supplierRef(refNo: number): string {
|
||||||
return `L-${String(refNo).padStart(3, "0")}`;
|
return `L-${String(refNo).padStart(3, "0")}`;
|
||||||
}
|
}
|
||||||
|
export function serviceRef(refNo: number): string {
|
||||||
|
return `S-${String(refNo).padStart(3, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
export const SUPPLIER_STATUS_TONE = {
|
export const SUPPLIER_LIFECYCLE_TONE = {
|
||||||
ACTIVE: "ok",
|
ACTIVE: "ok",
|
||||||
ONBOARDING: "info",
|
ONBOARDING: "info",
|
||||||
UNDER_REVIEW: "warn",
|
UNDER_REVIEW: "warn",
|
||||||
@@ -10,36 +19,160 @@ export const SUPPLIER_STATUS_TONE = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const DAY = 24 * 3600 * 1000;
|
const DAY = 24 * 3600 * 1000;
|
||||||
|
export function isExpired(d: Date | null | undefined): boolean {
|
||||||
/** true, wenn valid_to innerhalb der nächsten `days` Tage liegt (oder bereits abgelaufen). */
|
return !!d && d.getTime() < Date.now();
|
||||||
export function isExpiring(validTo: Date | null | undefined, days = 90): boolean {
|
}
|
||||||
if (!validTo) return false;
|
export function isExpiring(d: Date | null | undefined, days = 90): boolean {
|
||||||
return validTo.getTime() - Date.now() < days * DAY;
|
return !!d && d.getTime() - Date.now() < days * DAY;
|
||||||
|
}
|
||||||
|
export function isReviewDue(d: Date | null | undefined, days = 30): boolean {
|
||||||
|
return !!d && d.getTime() < Date.now() + days * DAY;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isExpired(validTo: Date | null | undefined): boolean {
|
/** Schutzbedarf 1(normal)/2(hoch)/3(sehr hoch) aus der höchsten C/I/A-Stufe. */
|
||||||
if (!validTo) return false;
|
export type ProtectionLevel = 1 | 2 | 3;
|
||||||
return validTo.getTime() < Date.now();
|
export function protectionLevel(maxCia: number): ProtectionLevel {
|
||||||
|
if (maxCia >= 4) return 3;
|
||||||
|
if (maxCia >= 3) return 2;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
export const PROTECTION_LABEL: Record<ProtectionLevel, string> = {
|
||||||
|
1: "normal",
|
||||||
|
2: "hoch",
|
||||||
|
3: "sehr hoch",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Kontext für die Statusableitung — aus dem Lieferanten-Datensatz berechnet. */
|
||||||
|
export type ReqContext = {
|
||||||
|
hasValidNda: boolean;
|
||||||
|
ndaMonitored: boolean;
|
||||||
|
hasSecurityContract: boolean;
|
||||||
|
customerReqPassed: boolean;
|
||||||
|
hasFlowdown: boolean;
|
||||||
|
hasAssessment: boolean;
|
||||||
|
hasReviewedAssessment: boolean;
|
||||||
|
hasAdequateEvidence: boolean;
|
||||||
|
complianceMonitored: boolean;
|
||||||
|
hasLinkedRisk: boolean;
|
||||||
|
hasManagementDecision: boolean;
|
||||||
|
hasAudit: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Tier = "must" | "should" | "high" | "veryhigh";
|
||||||
|
export type ReqStatus = "met" | "partial" | "open" | "na";
|
||||||
|
export type ReqTheme = "contract" | "assessment" | "transparency";
|
||||||
|
|
||||||
|
export type Requirement = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
control: "6.1.1" | "6.1.2" | "6.1.3";
|
||||||
|
tier: Tier;
|
||||||
|
theme: ReqTheme;
|
||||||
|
met: (c: ReqContext) => boolean;
|
||||||
|
refLabel?: (c: ReqContext) => string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const REQUIREMENTS: Requirement[] = [
|
||||||
|
// Vertrag & Geheimhaltung
|
||||||
|
{ key: "nda_valid", label: "NDA abgeschlossen und gültig", control: "6.1.2", tier: "must", theme: "contract", met: (c) => c.hasValidNda },
|
||||||
|
{ key: "infosec_contract", label: "Vertragliche InfoSec-Vereinbarung geschlossen", control: "6.1.1", tier: "must", theme: "contract", met: (c) => c.hasSecurityContract },
|
||||||
|
{ key: "customer_req_passed", label: "Kundenanforderungen an Partner weitergegeben", control: "6.1.1", tier: "must", theme: "contract", met: (c) => c.customerReqPassed },
|
||||||
|
{ key: "flowdown", label: "Flow-down an Subunternehmer verpflichtet", control: "6.1.1", tier: "should", theme: "contract", met: (c) => c.hasFlowdown },
|
||||||
|
{ key: "nda_monitoring", label: "NDA-Fristen-Monitoring aktiv", control: "6.1.2", tier: "should", theme: "contract", met: (c) => c.ndaMonitored },
|
||||||
|
// Bewertung & Nachweise
|
||||||
|
{ key: "risk_assessment", label: "Sicherheits-Risikobewertung durchgeführt", control: "6.1.1", tier: "must", theme: "assessment", met: (c) => c.hasAssessment || c.hasLinkedRisk },
|
||||||
|
{ key: "reports_reviewed", label: "Service-Reports & Dokumente geprüft", control: "6.1.1", tier: "should", theme: "assessment", met: (c) => c.hasReviewedAssessment },
|
||||||
|
{ key: "adequate_evidence", label: "Nachweis angemessenes Sicherheitsniveau (Zertifikat/Audit)", control: "6.1.1", tier: "high", theme: "assessment", met: (c) => c.hasAdequateEvidence },
|
||||||
|
{ key: "compliance_monitored", label: "Compliance-Grad dokumentiert & anlassbezogen überwacht", control: "6.1.1", tier: "high", theme: "assessment", met: (c) => c.complianceMonitored },
|
||||||
|
// Transparenz / sehr hoch
|
||||||
|
{ key: "customer_transparency", label: "Kundentransparenz zu Lieferkettenrisiken erfüllt", control: "6.1.1", tier: "veryhigh", theme: "transparency", met: (c) => c.customerReqPassed },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TIER_MIN_LEVEL: Record<Tier, ProtectionLevel> = { must: 1, should: 1, high: 2, veryhigh: 3 };
|
||||||
|
|
||||||
|
export function tierActive(tier: Tier, level: ProtectionLevel): boolean {
|
||||||
|
return level >= TIER_MIN_LEVEL[tier];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Review innerhalb der nächsten `days` Tage fällig (oder überfällig)? */
|
export function requirementStatus(req: Requirement, ctx: ReqContext, level: ProtectionLevel): ReqStatus {
|
||||||
export function isReviewDue(nextReview: Date | null | undefined, days = 30): boolean {
|
if (!tierActive(req.tier, level)) return "na";
|
||||||
if (!nextReview) return false;
|
return req.met(ctx) ? "met" : "open";
|
||||||
return nextReview.getTime() < Date.now() + days * DAY;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Gate: sehr hoher Schutzbedarf ohne gültiges Audit/Label → Kompensation nötig. */
|
||||||
* NIS2/VDA-ISA 6.1.1: Ist eine risikobasierte Managemententscheidung nötig?
|
export function gateActive(level: ProtectionLevel, ctx: ReqContext): boolean {
|
||||||
* → wenn KEIN gültiger, angemessenheitsgeprüfter Audit-/TISAX-Nachweis vorliegt.
|
return level === 3 && !ctx.hasAudit;
|
||||||
*/
|
}
|
||||||
export function needsManagementDecision(
|
export function gateSatisfied(ctx: ReqContext): boolean {
|
||||||
evidence: { kind: string; adequacyChecked: boolean; validTo: Date | null }[]
|
return ctx.hasAudit || (ctx.hasManagementDecision && ctx.hasLinkedRisk);
|
||||||
): boolean {
|
}
|
||||||
const hasAdequate = evidence.some(
|
|
||||||
|
export type Conformity = "conform" | "partial" | "gaps" | "action";
|
||||||
|
|
||||||
|
export function conformity(level: ProtectionLevel, ctx: ReqContext): Conformity {
|
||||||
|
if (gateActive(level, ctx) && !gateSatisfied(ctx)) return "action";
|
||||||
|
const active = REQUIREMENTS.filter((r) => tierActive(r.tier, level));
|
||||||
|
const met = active.filter((r) => r.met(ctx)).length;
|
||||||
|
if (met === active.length) return "conform";
|
||||||
|
if (met >= Math.ceil(active.length * 0.6)) return "partial";
|
||||||
|
return "gaps";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CONFORMITY_TONE: Record<Conformity, "ok" | "warn" | "risk"> = {
|
||||||
|
conform: "ok",
|
||||||
|
partial: "warn",
|
||||||
|
gaps: "risk",
|
||||||
|
action: "risk",
|
||||||
|
};
|
||||||
|
export const CONFORMITY_LABEL: Record<Conformity, string> = {
|
||||||
|
conform: "Konform",
|
||||||
|
partial: "Teilweise",
|
||||||
|
gaps: "Lücken offen",
|
||||||
|
action: "Handlung nötig",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Berechneter Reifegrad (0–3) aus Erfüllungsgrad der aktiven Anforderungen. */
|
||||||
|
export function computedMaturity(level: ProtectionLevel, ctx: ReqContext): number {
|
||||||
|
const active = REQUIREMENTS.filter((r) => tierActive(r.tier, level));
|
||||||
|
if (active.length === 0) return 0;
|
||||||
|
const met = active.filter((r) => r.met(ctx)).length;
|
||||||
|
return Math.round((met / active.length) * 3 * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TARGET_MATURITY = 3.0;
|
||||||
|
|
||||||
|
type CtxInput = {
|
||||||
|
contracts: { securityClauses: boolean; flowdown: boolean; customerRequirementsPassed: boolean }[];
|
||||||
|
ndas: { validTo: Date | null }[];
|
||||||
|
evidence: { kind: string; adequacyChecked: boolean; validTo: Date | null }[];
|
||||||
|
assessments: { result: string | null; nextReview: Date | null }[];
|
||||||
|
subcontractors: { flowdownObligation: boolean }[];
|
||||||
|
nextReview: Date | null;
|
||||||
|
linkedRiskCount: number;
|
||||||
|
decisionCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Baut den ReqContext aus den hinterlegten Lieferanten-Objekten. */
|
||||||
|
export function buildReqContext(d: CtxInput): ReqContext {
|
||||||
|
const hasAudit = d.evidence.some(
|
||||||
(e) =>
|
(e) =>
|
||||||
(e.kind === "AUDIT_REPORT" || e.kind === "TISAX_LABEL" || e.kind === "CERTIFICATE") &&
|
(e.kind === "AUDIT_REPORT" || e.kind === "TISAX_LABEL") &&
|
||||||
e.adequacyChecked &&
|
e.adequacyChecked &&
|
||||||
!isExpired(e.validTo)
|
!isExpired(e.validTo)
|
||||||
);
|
);
|
||||||
return !hasAdequate;
|
const hasAdequateEvidence = d.evidence.some((e) => e.adequacyChecked && !isExpired(e.validTo));
|
||||||
|
return {
|
||||||
|
hasValidNda: d.ndas.some((n) => !isExpired(n.validTo)),
|
||||||
|
ndaMonitored: d.ndas.length > 0 && !!d.nextReview,
|
||||||
|
hasSecurityContract: d.contracts.some((c) => c.securityClauses),
|
||||||
|
customerReqPassed: d.contracts.some((c) => c.customerRequirementsPassed),
|
||||||
|
hasFlowdown: d.contracts.some((c) => c.flowdown) || d.subcontractors.some((s) => s.flowdownObligation),
|
||||||
|
hasAssessment: d.assessments.length > 0,
|
||||||
|
hasReviewedAssessment: d.assessments.some((a) => !!a.result),
|
||||||
|
hasAdequateEvidence,
|
||||||
|
complianceMonitored: d.assessments.some((a) => !!a.nextReview),
|
||||||
|
hasLinkedRisk: d.linkedRiskCount > 0,
|
||||||
|
hasManagementDecision: d.decisionCount > 0,
|
||||||
|
hasAudit,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const level = z.coerce.number().int().min(1).max(4);
|
|||||||
const assetSchema = z.object({
|
const assetSchema = z.object({
|
||||||
name: z.string().trim().min(1).max(200),
|
name: z.string().trim().min(1).max(200),
|
||||||
description: z.string().trim().max(5000).optional(),
|
description: z.string().trim().max(5000).optional(),
|
||||||
type: z.enum(["INFORMATION", "SYSTEM", "APPLICATION", "LOCATION", "SUPPLIER", "PERSON", "DATA"]),
|
type: z.enum(["INFORMATION", "SYSTEM", "APPLICATION", "LOCATION", "SUPPLIER", "IT_SERVICE", "PERSON", "DATA"]),
|
||||||
status: z.enum(["ACTIVE", "PLANNED", "RETIRED"]),
|
status: z.enum(["ACTIVE", "PLANNED", "RETIRED"]),
|
||||||
ownerId: z.string().optional(),
|
ownerId: z.string().optional(),
|
||||||
location: z.string().trim().max(200).optional(),
|
location: z.string().trim().max(200).optional(),
|
||||||
|
|||||||
+126
-155
@@ -13,51 +13,55 @@ const level = z.coerce.number().int().min(1).max(4);
|
|||||||
const supplierSchema = z.object({
|
const supplierSchema = z.object({
|
||||||
name: z.string().trim().min(1).max(200),
|
name: z.string().trim().min(1).max(200),
|
||||||
sector: z.string().trim().max(200).optional(),
|
sector: z.string().trim().max(200).optional(),
|
||||||
services: z.string().trim().max(2000).optional(),
|
serviceDesc: z.string().trim().max(2000).optional(),
|
||||||
criticality: level,
|
criticality: level,
|
||||||
dataCategories: z.string().optional(),
|
dataCategories: z.string().optional(),
|
||||||
confidentiality: level,
|
confidentiality: level,
|
||||||
integrity: level,
|
integrity: level,
|
||||||
availability: level,
|
availability: level,
|
||||||
nis2Relevant: z.union([z.literal("on"), z.literal(null), z.string()]).optional(),
|
nis2Relevant: z.string().optional(),
|
||||||
status: z.enum(["ACTIVE", "ONBOARDING", "UNDER_REVIEW", "OFFBOARDED"]),
|
lifecycle: z.enum(["ACTIVE", "ONBOARDING", "UNDER_REVIEW", "OFFBOARDED"]),
|
||||||
contact: z.string().trim().max(200).optional(),
|
contact: z.string().trim().max(200).optional(),
|
||||||
nextReview: z.string().optional(),
|
nextReview: z.string().optional(),
|
||||||
notes: z.string().trim().max(5000).optional(),
|
notes: z.string().trim().max(5000).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
function parseSupplier(formData: FormData) {
|
function parse(formData: FormData) {
|
||||||
const p = supplierSchema.parse({
|
const p = supplierSchema.parse({
|
||||||
name: formData.get("name"),
|
name: formData.get("name"),
|
||||||
sector: formData.get("sector") || undefined,
|
sector: formData.get("sector") || undefined,
|
||||||
services: formData.get("services") || undefined,
|
serviceDesc: formData.get("serviceDesc") || undefined,
|
||||||
criticality: formData.get("criticality"),
|
criticality: formData.get("criticality"),
|
||||||
dataCategories: formData.get("dataCategories") || undefined,
|
dataCategories: formData.get("dataCategories") || undefined,
|
||||||
confidentiality: formData.get("confidentiality"),
|
confidentiality: formData.get("confidentiality"),
|
||||||
integrity: formData.get("integrity"),
|
integrity: formData.get("integrity"),
|
||||||
availability: formData.get("availability"),
|
availability: formData.get("availability"),
|
||||||
nis2Relevant: formData.get("nis2Relevant"),
|
nis2Relevant: formData.get("nis2Relevant") || undefined,
|
||||||
status: formData.get("status"),
|
lifecycle: formData.get("lifecycle"),
|
||||||
contact: formData.get("contact") || undefined,
|
contact: formData.get("contact") || undefined,
|
||||||
nextReview: formData.get("nextReview") || undefined,
|
nextReview: formData.get("nextReview") || undefined,
|
||||||
notes: formData.get("notes") || undefined,
|
notes: formData.get("notes") || undefined,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
|
asset: {
|
||||||
name: p.name,
|
name: p.name,
|
||||||
|
confidentiality: p.confidentiality,
|
||||||
|
integrity: p.integrity,
|
||||||
|
availability: p.availability,
|
||||||
|
},
|
||||||
|
profile: {
|
||||||
sector: p.sector || null,
|
sector: p.sector || null,
|
||||||
services: p.services || null,
|
serviceDesc: p.serviceDesc || null,
|
||||||
criticality: p.criticality,
|
criticality: p.criticality,
|
||||||
dataCategories: p.dataCategories
|
dataCategories: p.dataCategories
|
||||||
? p.dataCategories.split(",").map((s) => s.trim()).filter(Boolean)
|
? p.dataCategories.split(",").map((s) => s.trim()).filter(Boolean)
|
||||||
: [],
|
: [],
|
||||||
confidentiality: p.confidentiality,
|
|
||||||
integrity: p.integrity,
|
|
||||||
availability: p.availability,
|
|
||||||
nis2Relevant: p.nis2Relevant === "on",
|
nis2Relevant: p.nis2Relevant === "on",
|
||||||
status: p.status,
|
lifecycle: p.lifecycle,
|
||||||
contact: p.contact || null,
|
contact: p.contact || null,
|
||||||
nextReview: p.nextReview ? new Date(p.nextReview) : null,
|
nextReview: p.nextReview ? new Date(p.nextReview) : null,
|
||||||
notes: p.notes || null,
|
notes: p.notes || null,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,216 +69,158 @@ export async function createSupplier(formData: FormData) {
|
|||||||
const session = await requireSession();
|
const session = await requireSession();
|
||||||
requirePermission(session, "supplier:write");
|
requirePermission(session, "supplier:write");
|
||||||
const db = dbForTenant(session.user.tenantId);
|
const db = dbForTenant(session.user.tenantId);
|
||||||
|
const { asset, profile } = parse(formData);
|
||||||
|
|
||||||
const data = parseSupplier(formData);
|
const created = await db.asset.create({
|
||||||
const last = await prisma.supplier.aggregate({
|
data: {
|
||||||
|
...asset,
|
||||||
|
type: "SUPPLIER",
|
||||||
|
tenantId: session.user.tenantId,
|
||||||
|
createdBy: session.user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const last = await prisma.supplierProfile.aggregate({
|
||||||
where: { tenantId: session.user.tenantId },
|
where: { tenantId: session.user.tenantId },
|
||||||
_max: { refNo: true },
|
_max: { refNo: true },
|
||||||
});
|
});
|
||||||
const supplier = await db.supplier.create({
|
await db.supplierProfile.create({
|
||||||
data: {
|
data: {
|
||||||
...data,
|
...profile,
|
||||||
|
assetId: created.id,
|
||||||
refNo: (last._max.refNo ?? 0) + 1,
|
refNo: (last._max.refNo ?? 0) + 1,
|
||||||
tenantId: session.user.tenantId,
|
tenantId: session.user.tenantId,
|
||||||
createdBy: session.user.id,
|
createdBy: session.user.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await writeAuditLog({
|
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "supplier", entityId: created.id, after: { ...asset, ...profile } });
|
||||||
tenantId: session.user.tenantId,
|
|
||||||
actorId: session.user.id,
|
|
||||||
action: "create",
|
|
||||||
entity: "supplier",
|
|
||||||
entityId: supplier.id,
|
|
||||||
after: data,
|
|
||||||
});
|
|
||||||
revalidatePath("/suppliers");
|
revalidatePath("/suppliers");
|
||||||
redirect(`/suppliers?edit=${supplier.id}`);
|
revalidatePath("/assets");
|
||||||
|
redirect(`/suppliers?edit=${created.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateSupplier(supplierId: string, formData: FormData) {
|
export async function updateSupplier(assetId: string, formData: FormData) {
|
||||||
const session = await requireSession();
|
const session = await requireSession();
|
||||||
requirePermission(session, "supplier:write");
|
requirePermission(session, "supplier:write");
|
||||||
const db = dbForTenant(session.user.tenantId);
|
const db = dbForTenant(session.user.tenantId);
|
||||||
|
const before = await db.asset.findUnique({ where: { id: assetId }, include: { supplierProfile: true } });
|
||||||
const before = await db.supplier.findUnique({ where: { id: supplierId } });
|
|
||||||
if (!before) throw new Error("Lieferant nicht gefunden");
|
if (!before) throw new Error("Lieferant nicht gefunden");
|
||||||
const data = parseSupplier(formData);
|
const { asset, profile } = parse(formData);
|
||||||
await db.supplier.update({ where: { id: supplierId }, data });
|
await db.asset.update({ where: { id: assetId }, data: asset });
|
||||||
await writeAuditLog({
|
await db.supplierProfile.update({ where: { assetId }, data: profile });
|
||||||
tenantId: session.user.tenantId,
|
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "supplier", entityId: assetId, before, after: { ...asset, ...profile } });
|
||||||
actorId: session.user.id,
|
|
||||||
action: "update",
|
|
||||||
entity: "supplier",
|
|
||||||
entityId: supplierId,
|
|
||||||
before,
|
|
||||||
after: data,
|
|
||||||
});
|
|
||||||
revalidatePath("/suppliers");
|
revalidatePath("/suppliers");
|
||||||
redirect(`/suppliers?detail=${supplierId}`);
|
revalidatePath("/assets");
|
||||||
|
redirect(`/suppliers?detail=${assetId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteSupplier(supplierId: string) {
|
export async function deleteSupplier(assetId: string) {
|
||||||
const session = await requireSession();
|
const session = await requireSession();
|
||||||
requirePermission(session, "supplier:write");
|
requirePermission(session, "supplier:write");
|
||||||
const db = dbForTenant(session.user.tenantId);
|
const db = dbForTenant(session.user.tenantId);
|
||||||
const before = await db.supplier.findUnique({ where: { id: supplierId } });
|
const before = await db.asset.findUnique({ where: { id: assetId } });
|
||||||
if (!before) throw new Error("Lieferant nicht gefunden");
|
if (!before) throw new Error("Lieferant nicht gefunden");
|
||||||
await db.supplier.delete({ where: { id: supplierId } });
|
await db.asset.delete({ where: { id: assetId } }); // Profil + Kinder kaskadieren
|
||||||
await writeAuditLog({
|
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "supplier", entityId: assetId, before });
|
||||||
tenantId: session.user.tenantId,
|
|
||||||
actorId: session.user.id,
|
|
||||||
action: "delete",
|
|
||||||
entity: "supplier",
|
|
||||||
entityId: supplierId,
|
|
||||||
before,
|
|
||||||
});
|
|
||||||
revalidatePath("/suppliers");
|
revalidatePath("/suppliers");
|
||||||
|
revalidatePath("/assets");
|
||||||
redirect("/suppliers");
|
redirect("/suppliers");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Generischer Helper zum Anlegen einer Kind-Entität (mit Tenant/Owner-Prüfung). */
|
async function ensure(session: Awaited<ReturnType<typeof requireSession>>, assetId: string) {
|
||||||
async function ensureSupplier(session: Awaited<ReturnType<typeof requireSession>>, supplierId: string) {
|
|
||||||
const db = dbForTenant(session.user.tenantId);
|
const db = dbForTenant(session.user.tenantId);
|
||||||
const count = await db.supplier.count({ where: { id: supplierId } });
|
if ((await db.asset.count({ where: { id: assetId } })) !== 1) throw new Error("Nicht gefunden");
|
||||||
if (count !== 1) throw new Error("Lieferant nicht gefunden");
|
|
||||||
return db;
|
return db;
|
||||||
}
|
}
|
||||||
|
const optDate = (v: FormDataEntryValue | null) => (v && String(v) ? new Date(String(v)) : null);
|
||||||
|
|
||||||
const optDate = (v: FormDataEntryValue | null) =>
|
export async function addContract(assetId: string, formData: FormData) {
|
||||||
v && String(v) ? new Date(String(v)) : null;
|
|
||||||
|
|
||||||
export async function addAssessment(supplierId: string, formData: FormData) {
|
|
||||||
const session = await requireSession();
|
const session = await requireSession();
|
||||||
requirePermission(session, "supplier:write");
|
requirePermission(session, "supplier:write");
|
||||||
const db = await ensureSupplier(session, supplierId);
|
const db = await ensure(session, assetId);
|
||||||
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";
|
const bool = (n: string) => formData.get(n) === "on";
|
||||||
await db.contract.create({
|
await db.contract.create({
|
||||||
data: {
|
data: {
|
||||||
supplierId,
|
assetId,
|
||||||
tenantId: session.user.tenantId,
|
tenantId: session.user.tenantId,
|
||||||
type: (formData.get("type") as string) || "service",
|
type: (formData.get("type") as string) || "service",
|
||||||
avDpa: bool("avDpa"),
|
avDpa: bool("avDpa"),
|
||||||
securityClauses: bool("securityClauses"),
|
securityClauses: bool("securityClauses"),
|
||||||
flowdown: bool("flowdown"),
|
flowdown: bool("flowdown"),
|
||||||
customerTransparency: bool("customerTransparency"),
|
customerRequirementsPassed: bool("customerRequirementsPassed"),
|
||||||
validFrom: optDate(formData.get("validFrom")),
|
validFrom: optDate(formData.get("validFrom")),
|
||||||
validTo: optDate(formData.get("validTo")),
|
validTo: optDate(formData.get("validTo")),
|
||||||
reference: (formData.get("reference") as string)?.trim() || null,
|
reference: (formData.get("reference") as string)?.trim() || null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "contract", entityId: supplierId });
|
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "contract", entityId: assetId });
|
||||||
revalidatePath("/suppliers");
|
revalidatePath("/suppliers");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addNda(supplierId: string, formData: FormData) {
|
export async function addNda(assetId: string, formData: FormData) {
|
||||||
const session = await requireSession();
|
const session = await requireSession();
|
||||||
requirePermission(session, "supplier:write");
|
requirePermission(session, "supplier:write");
|
||||||
const db = await ensureSupplier(session, supplierId);
|
const db = await ensure(session, assetId);
|
||||||
await db.nda.create({
|
await db.nda.create({
|
||||||
data: {
|
data: {
|
||||||
supplierId,
|
assetId,
|
||||||
tenantId: session.user.tenantId,
|
tenantId: session.user.tenantId,
|
||||||
parties: (formData.get("parties") as string)?.trim() || null,
|
parties: (formData.get("parties") as string)?.trim() || null,
|
||||||
infoScope: (formData.get("infoScope") as string)?.trim() || null,
|
infoScope: (formData.get("infoScope") as string)?.trim() || null,
|
||||||
subject: (formData.get("subject") as string)?.trim() || null,
|
subject: (formData.get("subject") as string)?.trim() || null,
|
||||||
validFrom: optDate(formData.get("validFrom")),
|
validFrom: optDate(formData.get("validFrom")),
|
||||||
validTo: optDate(formData.get("validTo")),
|
validTo: optDate(formData.get("validTo")),
|
||||||
obligations: (formData.get("obligations") as string)?.trim() || null,
|
|
||||||
extensionStatus: (formData.get("extensionStatus") 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 });
|
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "nda", entityId: assetId });
|
||||||
revalidatePath("/suppliers");
|
revalidatePath("/suppliers");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addEvidence(supplierId: string, formData: FormData) {
|
export async function addEvidence(assetId: string, formData: FormData) {
|
||||||
const session = await requireSession();
|
const session = await requireSession();
|
||||||
requirePermission(session, "supplier:write");
|
requirePermission(session, "supplier:write");
|
||||||
const db = await ensureSupplier(session, supplierId);
|
const db = await ensure(session, assetId);
|
||||||
await db.supplierEvidence.create({
|
await db.supplierEvidence.create({
|
||||||
data: {
|
data: {
|
||||||
supplierId,
|
assetId,
|
||||||
tenantId: session.user.tenantId,
|
tenantId: session.user.tenantId,
|
||||||
kind: z
|
kind: z.enum(["CERTIFICATE", "TISAX_LABEL", "ATTESTATION", "AUDIT_REPORT", "SELF_ASSESSMENT"]).parse(formData.get("kind")),
|
||||||
.enum(["CERTIFICATE", "TISAX_LABEL", "ATTESTATION", "AUDIT_REPORT", "SELF_ASSESSMENT"])
|
|
||||||
.parse(formData.get("kind")),
|
|
||||||
name: (formData.get("name") as string)?.trim() || null,
|
name: (formData.get("name") as string)?.trim() || null,
|
||||||
protectsCia: (formData.get("protectsCia") as string)?.trim() || null,
|
protectsCia: (formData.get("protectsCia") as string)?.trim() || null,
|
||||||
validTo: optDate(formData.get("validTo")),
|
validTo: optDate(formData.get("validTo")),
|
||||||
adequacyChecked: formData.get("adequacyChecked") === "on",
|
adequacyChecked: formData.get("adequacyChecked") === "on",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "supplier_evidence", entityId: supplierId });
|
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "supplier_evidence", entityId: assetId });
|
||||||
revalidatePath("/suppliers");
|
revalidatePath("/suppliers");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addResponsibility(supplierId: string, formData: FormData) {
|
export async function addAssessment(assetId: string, formData: FormData) {
|
||||||
const session = await requireSession();
|
const session = await requireSession();
|
||||||
requirePermission(session, "supplier:write");
|
requirePermission(session, "supplier:write");
|
||||||
const db = await ensureSupplier(session, supplierId);
|
const db = await ensure(session, assetId);
|
||||||
await db.serviceResponsibility.create({
|
await db.supplierAssessment.create({
|
||||||
data: {
|
data: {
|
||||||
supplierId,
|
assetId,
|
||||||
tenantId: session.user.tenantId,
|
tenantId: session.user.tenantId,
|
||||||
itService: z.string().trim().min(1).parse(formData.get("itService")),
|
type: z.enum(["QUESTIONNAIRE", "SELF_ASSESSMENT", "AUDIT"]).parse(formData.get("type")),
|
||||||
requirement: z.string().trim().min(1).parse(formData.get("requirement")),
|
score: formData.get("score") ? Number(formData.get("score")) : null,
|
||||||
responsibleParty: z
|
date: optDate(formData.get("date")),
|
||||||
.enum(["CLIENT", "SUPPLIER", "SHARED"])
|
nextReview: optDate(formData.get("nextReview")),
|
||||||
.parse(formData.get("responsibleParty") ?? "SHARED"),
|
result: (formData.get("result") as string)?.trim() || null,
|
||||||
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 });
|
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "supplier_assessment", entityId: assetId });
|
||||||
revalidatePath("/suppliers");
|
revalidatePath("/suppliers");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addSubcontractor(supplierId: string, formData: FormData) {
|
export async function addDecision(assetId: string, formData: FormData) {
|
||||||
const session = await requireSession();
|
const session = await requireSession();
|
||||||
requirePermission(session, "supplier:write");
|
requirePermission(session, "supplier:write");
|
||||||
const db = await ensureSupplier(session, supplierId);
|
const db = await ensure(session, assetId);
|
||||||
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({
|
await db.managementDecision.create({
|
||||||
data: {
|
data: {
|
||||||
supplierId,
|
assetId,
|
||||||
tenantId: session.user.tenantId,
|
tenantId: session.user.tenantId,
|
||||||
reasonNoAudit: z.string().trim().min(1).parse(formData.get("reasonNoAudit")),
|
reasonNoAudit: z.string().trim().min(1).parse(formData.get("reasonNoAudit")),
|
||||||
decision: z.string().trim().min(1).parse(formData.get("decision")),
|
decision: z.string().trim().min(1).parse(formData.get("decision")),
|
||||||
@@ -282,53 +228,78 @@ export async function addDecision(supplierId: string, formData: FormData) {
|
|||||||
recordRef: (formData.get("recordRef") as string)?.trim() || 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 });
|
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "management_decision", entityId: assetId });
|
||||||
revalidatePath("/suppliers");
|
revalidatePath("/suppliers");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveMaturity(supplierId: string, controlId: string, formData: FormData) {
|
/** Gate-Kompensation: echtes Risiko im Risikomodul anlegen und mit dem Lieferanten verknüpfen. */
|
||||||
await setControlMaturity(supplierId, controlId, Number(formData.get("maturity")));
|
export async function createGateRisk(assetId: string) {
|
||||||
}
|
|
||||||
|
|
||||||
export async function setControlMaturity(supplierId: string, controlId: string, maturity: number) {
|
|
||||||
const session = await requireSession();
|
const session = await requireSession();
|
||||||
requirePermission(session, "supplier:write");
|
requirePermission(session, "supplier:write");
|
||||||
const db = await ensureSupplier(session, supplierId);
|
requirePermission(session, "risk:write");
|
||||||
const m = z.coerce.number().int().min(0).max(5).parse(maturity);
|
const db = dbForTenant(session.user.tenantId);
|
||||||
await db.supplierControlMaturity.upsert({
|
const asset = await db.asset.findUnique({ where: { id: assetId } });
|
||||||
where: { supplierId_controlId: { supplierId, controlId } },
|
if (!asset) throw new Error("Lieferant nicht gefunden");
|
||||||
update: { maturity: m },
|
|
||||||
create: { supplierId, controlId, maturity: m, tenantId: session.user.tenantId },
|
const last = await prisma.risk.aggregate({ where: { tenantId: session.user.tenantId }, _max: { refNo: true } });
|
||||||
|
const risk = await db.risk.create({
|
||||||
|
data: {
|
||||||
|
tenantId: session.user.tenantId,
|
||||||
|
refNo: (last._max.refNo ?? 0) + 1,
|
||||||
|
title: `Third-Party-Risiko: ${asset.name}`,
|
||||||
|
description: "Sehr hoher Schutzbedarf ohne gültiges Third-Party-Audit/TISAX-Label — Kompensationsrisiko (VDA-ISA 6.1.1).",
|
||||||
|
threat: "Ausfall/Kompromittierung eines kritischen Lieferanten",
|
||||||
|
vulnerability: "Fehlender unabhängiger Sicherheitsnachweis",
|
||||||
|
likelihood: 3,
|
||||||
|
impact: 4,
|
||||||
|
score: 12,
|
||||||
|
treatment: "MITIGATE",
|
||||||
|
status: "IN_TREATMENT",
|
||||||
|
createdBy: session.user.id,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "supplier_control_maturity", entityId: supplierId, after: { controlId, maturity: m } });
|
await db.riskAsset.create({ data: { tenantId: session.user.tenantId, riskId: risk.id, assetId } });
|
||||||
|
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "risk", entityId: risk.id, after: { gateRiskFor: assetId } });
|
||||||
|
revalidatePath("/suppliers");
|
||||||
|
revalidatePath("/risks");
|
||||||
|
redirect(`/risks?detail=${risk.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ISB-Freigabe des Reifegrads (Abweichung nur mit Begründung → Audit-Log). */
|
||||||
|
export async function approveMaturity(assetId: string, computed: number, formData: FormData) {
|
||||||
|
const session = await requireSession();
|
||||||
|
requirePermission(session, "supplier:write");
|
||||||
|
const db = await ensure(session, assetId);
|
||||||
|
const isbValue = z.coerce.number().min(0).max(3).parse(formData.get("isbValue"));
|
||||||
|
const justification = (formData.get("justification") as string)?.trim() || null;
|
||||||
|
if (Math.abs(isbValue - computed) > 0.01 && !justification) {
|
||||||
|
throw new Error("Abweichung vom berechneten Wert erfordert eine Begründung.");
|
||||||
|
}
|
||||||
|
await db.maturityAssessment.upsert({
|
||||||
|
where: { assetId },
|
||||||
|
update: { computedValue: computed, isbValue, isbJustification: justification, approvedBy: session.user.name, approvedAt: new Date() },
|
||||||
|
create: { assetId, tenantId: session.user.tenantId, computedValue: computed, isbValue, isbJustification: justification, approvedBy: session.user.name, approvedAt: new Date() },
|
||||||
|
});
|
||||||
|
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "maturity_assessment", entityId: assetId, after: { computed, isbValue, justification } });
|
||||||
revalidatePath("/suppliers");
|
revalidatePath("/suppliers");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Generisches Löschen einer Kind-Entität. */
|
|
||||||
export async function deleteChild(
|
export async function deleteChild(
|
||||||
kind:
|
kind: "contract" | "nda" | "evidence" | "assessment" | "decision" | "subcontractor",
|
||||||
| "assessment"
|
|
||||||
| "contract"
|
|
||||||
| "nda"
|
|
||||||
| "evidence"
|
|
||||||
| "responsibility"
|
|
||||||
| "subcontractor"
|
|
||||||
| "decision",
|
|
||||||
id: string
|
id: string
|
||||||
) {
|
) {
|
||||||
const session = await requireSession();
|
const session = await requireSession();
|
||||||
requirePermission(session, "supplier:write");
|
requirePermission(session, "supplier:write");
|
||||||
const db = dbForTenant(session.user.tenantId);
|
const db = dbForTenant(session.user.tenantId);
|
||||||
const map = {
|
const map = {
|
||||||
assessment: db.supplierAssessment,
|
|
||||||
contract: db.contract,
|
contract: db.contract,
|
||||||
nda: db.nda,
|
nda: db.nda,
|
||||||
evidence: db.supplierEvidence,
|
evidence: db.supplierEvidence,
|
||||||
responsibility: db.serviceResponsibility,
|
assessment: db.supplierAssessment,
|
||||||
subcontractor: db.subcontractor,
|
|
||||||
decision: db.managementDecision,
|
decision: db.managementDecision,
|
||||||
|
subcontractor: db.subcontractor,
|
||||||
} as const;
|
} as const;
|
||||||
// @ts-expect-error dynamischer Delegate-Zugriff
|
// @ts-expect-error dynamischer Delegate
|
||||||
await map[kind].delete({ where: { id } });
|
await map[kind].delete({ where: { id } });
|
||||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: `supplier_${kind}`, entityId: id });
|
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: `supplier_${kind}`, entityId: id });
|
||||||
revalidatePath("/suppliers");
|
revalidatePath("/suppliers");
|
||||||
|
|||||||
+4
-4
@@ -37,16 +37,16 @@ const TENANT_MODELS = new Set<string>([
|
|||||||
"RiskAsset",
|
"RiskAsset",
|
||||||
"Measure",
|
"Measure",
|
||||||
"RiskMeasure",
|
"RiskMeasure",
|
||||||
"Supplier",
|
"SupplierProfile",
|
||||||
"SupplierAsset",
|
"ITServiceProfile",
|
||||||
"SupplierAssessment",
|
"SupplierAssessment",
|
||||||
"Contract",
|
"Contract",
|
||||||
"Nda",
|
"Nda",
|
||||||
"SupplierEvidence",
|
"SupplierEvidence",
|
||||||
"ServiceResponsibility",
|
"ServiceControlResponsibility",
|
||||||
"Subcontractor",
|
"Subcontractor",
|
||||||
"ManagementDecision",
|
"ManagementDecision",
|
||||||
"SupplierControlMaturity",
|
"MaturityAssessment",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export type GraphNodeKind =
|
|||||||
| "APPLICATION"
|
| "APPLICATION"
|
||||||
| "LOCATION"
|
| "LOCATION"
|
||||||
| "SUPPLIER"
|
| "SUPPLIER"
|
||||||
|
| "IT_SERVICE"
|
||||||
| "PERSON"
|
| "PERSON"
|
||||||
| "DATA";
|
| "DATA";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user