Statt beim Klick auf ein Lieferanten-/IT-Service-Asset zur Lieferanten-Seite zu wechseln, wird das Fach-Cockpit jetzt als Popup auf /assets gerendert; Schließen, Bearbeiten, Speichern und Löschen bleiben auf der Asset-Seite. - backHref-Prop an SupplierDetail/Edit- und ServiceDetail/Edit-Modal: alle Schließen-/Bearbeiten-/Abbrechen-Links leiten sich davon ab (Default /suppliers) - withQuery-Helper (lib/supplier) für korrekte ?/&-Verkettung - updateSupplier/updateService lesen returnTo (verstecktes Feld) und leiten dorthin zurück; deleteSupplier/deleteService bekommen returnTo-Parameter - Kind-Actions revalidieren zusätzlich /assets, damit das Popup dort aktuell bleibt - SUPPLIER_INCLUDE/SERVICE_INCLUDE in lib/supplier-include ausgelagert und von beiden Seiten genutzt - assets/page.tsx bestimmt die Popup-Art vorab (supplier/service/asset) und rendert das passende Cockpit mit backHref="/assets"; profillose Alt-Assets bleiben Assets Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
188 lines
7.8 KiB
TypeScript
188 lines
7.8 KiB
TypeScript
/**
|
||
* 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 {
|
||
return `L-${String(refNo).padStart(3, "0")}`;
|
||
}
|
||
export function serviceRef(refNo: number): string {
|
||
return `S-${String(refNo).padStart(3, "0")}`;
|
||
}
|
||
|
||
/**
|
||
* Hängt einen Query-Parameter an einen Basis-Pfad an und wählt automatisch
|
||
* das richtige Trennzeichen (`?` oder `&`). Erlaubt, Detail-/Bearbeiten-Popups
|
||
* sowohl auf /suppliers als auch auf /assets zu öffnen (backHref-Muster).
|
||
*/
|
||
export function withQuery(base: string, key: string, val: string): string {
|
||
return `${base}${base.includes("?") ? "&" : "?"}${key}=${val}`;
|
||
}
|
||
|
||
export const SUPPLIER_LIFECYCLE_TONE = {
|
||
ACTIVE: "ok",
|
||
ONBOARDING: "info",
|
||
UNDER_REVIEW: "warn",
|
||
OFFBOARDED: "mut",
|
||
} as const;
|
||
|
||
const DAY = 24 * 3600 * 1000;
|
||
export function isExpired(d: Date | null | undefined): boolean {
|
||
return !!d && d.getTime() < Date.now();
|
||
}
|
||
export function isExpiring(d: Date | null | undefined, days = 90): boolean {
|
||
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;
|
||
}
|
||
|
||
/** Schutzbedarf 1(normal)/2(hoch)/3(sehr hoch) aus der höchsten C/I/A-Stufe. */
|
||
export type ProtectionLevel = 1 | 2 | 3;
|
||
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];
|
||
}
|
||
|
||
export function requirementStatus(req: Requirement, ctx: ReqContext, level: ProtectionLevel): ReqStatus {
|
||
if (!tierActive(req.tier, level)) return "na";
|
||
return req.met(ctx) ? "met" : "open";
|
||
}
|
||
|
||
/** Gate: sehr hoher Schutzbedarf ohne gültiges Audit/Label → Kompensation nötig. */
|
||
export function gateActive(level: ProtectionLevel, ctx: ReqContext): boolean {
|
||
return level === 3 && !ctx.hasAudit;
|
||
}
|
||
export function gateSatisfied(ctx: ReqContext): boolean {
|
||
return ctx.hasAudit || (ctx.hasManagementDecision && ctx.hasLinkedRisk);
|
||
}
|
||
|
||
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.kind === "AUDIT_REPORT" || e.kind === "TISAX_LABEL") &&
|
||
e.adequacyChecked &&
|
||
!isExpired(e.validTo)
|
||
);
|
||
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,
|
||
};
|
||
}
|