feat(sdk): vendor-compliance cross-module integration — VVT, obligations, TOM, loeschfristen
Integrate the vendor-compliance module with four DSGVO modules to eliminate data silos and resolve the VVT processor tab's ephemeral state problem. - Reposition vendor-compliance sidebar from seq 4200 to 2500 (after VVT) - VVT: replace ephemeral ProcessorRecord state with Vendor-API fetch (read-only) - Obligations: add linked_vendor_ids (JSONB) + compliance check #12 MISSING_VENDOR_LINK - TOM: add vendor TOM-controls cross-reference table in overview tab - Loeschfristen: add linked_vendor_ids (JSONB) + vendor picker + document section - Migrations: 069_obligations_vendor_link.sql, 070_loeschfristen_vendor_link.sql - Tests: 12 new backend tests (125 total pass) - Docs: update obligations.md + vendors.md with cross-module integration Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -148,6 +148,21 @@ export function buildLoeschkonzeptHtml(
|
||||
}
|
||||
}
|
||||
|
||||
// Build vendor cross-reference data
|
||||
const vendorRefs: Array<{ policyName: string; policyId: string; vendorId: string; duration: string }> = []
|
||||
for (const p of activePolicies) {
|
||||
if (p.linkedVendorIds && p.linkedVendorIds.length > 0) {
|
||||
for (const vendorId of p.linkedVendorIds) {
|
||||
vendorRefs.push({
|
||||
policyName: p.dataObjectName || p.policyId,
|
||||
policyId: p.policyId,
|
||||
vendorId,
|
||||
duration: formatRetentionDuration(p.retentionDuration, p.retentionUnit),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// HTML Template
|
||||
// =========================================================================
|
||||
@@ -392,6 +407,7 @@ export function buildLoeschkonzeptHtml(
|
||||
'Loeschregeln-Uebersicht',
|
||||
'Detaillierte Loeschregeln',
|
||||
'VVT-Verknuepfung',
|
||||
'Auftragsverarbeiter mit Loeschpflichten',
|
||||
'Legal Hold Verfahren',
|
||||
'Verantwortlichkeiten',
|
||||
'Pruef- und Revisionszyklus',
|
||||
@@ -594,11 +610,47 @@ export function buildLoeschkonzeptHtml(
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 7: Legal Hold Verfahren
|
||||
// Section 7: Auftragsverarbeiter mit Loeschpflichten
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">7. Legal Hold Verfahren</div>
|
||||
<div class="section-header">7. Auftragsverarbeiter mit Loeschpflichten</div>
|
||||
<div class="section-body">
|
||||
<p>Die folgende Tabelle zeigt Loeschregeln, die mit Auftragsverarbeitern verknuepft sind.
|
||||
Diese Verknuepfungen stellen sicher, dass auch bei extern verarbeiteten Daten die Loeschpflichten
|
||||
eingehalten werden (Art. 28 DSGVO).</p>
|
||||
`
|
||||
if (vendorRefs.length > 0) {
|
||||
html += ` <table>
|
||||
<tr><th>Loeschregel</th><th>LF-Nr.</th><th>Auftragsverarbeiter (ID)</th><th>Aufbewahrungsfrist</th></tr>
|
||||
`
|
||||
for (const ref of vendorRefs) {
|
||||
html += ` <tr>
|
||||
<td>${escHtml(ref.policyName)}</td>
|
||||
<td>${escHtml(ref.policyId)}</td>
|
||||
<td>${escHtml(ref.vendorId)}</td>
|
||||
<td>${escHtml(ref.duration)}</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
html += ` </table>
|
||||
`
|
||||
} else {
|
||||
html += ` <p><em>Noch keine Auftragsverarbeiter mit Loeschregeln verknuepft. Verknuepfen Sie Ihre
|
||||
Loeschregeln mit den entsprechenden Auftragsverarbeitern im Editor-Tab.</em></p>
|
||||
`
|
||||
}
|
||||
|
||||
html += ` </div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 8: Legal Hold Verfahren
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">8. Legal Hold Verfahren</div>
|
||||
<div class="section-body">
|
||||
<p>Ein Legal Hold (Aufbewahrungspflicht aufgrund rechtlicher Verfahren) setzt die regulaere
|
||||
Loeschung aus. Betroffene Daten duerfen trotz abgelaufener Loeschfrist nicht geloescht werden,
|
||||
@@ -639,11 +691,11 @@ export function buildLoeschkonzeptHtml(
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 8: Verantwortlichkeiten
|
||||
// Section 9: Verantwortlichkeiten
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">8. Verantwortlichkeiten</div>
|
||||
<div class="section-header">9. Verantwortlichkeiten</div>
|
||||
<div class="section-body">
|
||||
<p>Die folgende Rollenmatrix zeigt, welche Organisationseinheiten fuer welche Datenobjekte
|
||||
die Loeschverantwortung tragen:</p>
|
||||
@@ -665,11 +717,11 @@ export function buildLoeschkonzeptHtml(
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 9: Pruef- und Revisionszyklus
|
||||
// Section 10: Pruef- und Revisionszyklus
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">9. Pruef- und Revisionszyklus</div>
|
||||
<div class="section-header">10. Pruef- und Revisionszyklus</div>
|
||||
<div class="section-body">
|
||||
<table>
|
||||
<tr><th>Eigenschaft</th><th>Wert</th></tr>
|
||||
@@ -691,11 +743,11 @@ export function buildLoeschkonzeptHtml(
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 10: Compliance-Status
|
||||
// Section 11: Compliance-Status
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section page-break">
|
||||
<div class="section-header">10. Compliance-Status</div>
|
||||
<div class="section-header">11. Compliance-Status</div>
|
||||
<div class="section-body">
|
||||
`
|
||||
if (complianceResult) {
|
||||
@@ -750,11 +802,11 @@ export function buildLoeschkonzeptHtml(
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 11: Aenderungshistorie
|
||||
// Section 12: Aenderungshistorie
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">11. Aenderungshistorie</div>
|
||||
<div class="section-header">12. Aenderungshistorie</div>
|
||||
<div class="section-body">
|
||||
<table>
|
||||
<tr><th>Version</th><th>Datum</th><th>Autor</th><th>Aenderungen</th></tr>
|
||||
|
||||
@@ -91,6 +91,7 @@ export interface LoeschfristPolicy {
|
||||
responsiblePerson: string
|
||||
releaseProcess: string
|
||||
linkedVVTActivityIds: string[]
|
||||
linkedVendorIds: string[]
|
||||
// Status & Review
|
||||
status: PolicyStatus
|
||||
lastReviewDate: string
|
||||
@@ -272,6 +273,7 @@ export function createEmptyPolicy(): LoeschfristPolicy {
|
||||
responsiblePerson: '',
|
||||
releaseProcess: '',
|
||||
linkedVVTActivityIds: [],
|
||||
linkedVendorIds: [],
|
||||
status: 'DRAFT',
|
||||
lastReviewDate: now,
|
||||
nextReviewDate: nextYear.toISOString(),
|
||||
|
||||
395
admin-compliance/lib/sdk/obligations-compliance.ts
Normal file
395
admin-compliance/lib/sdk/obligations-compliance.ts
Normal file
@@ -0,0 +1,395 @@
|
||||
// =============================================================================
|
||||
// Obligations Module - Compliance Check Engine
|
||||
// Prueft Pflichten auf Vollstaendigkeit, Konsistenz und Auditfaehigkeit
|
||||
// =============================================================================
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface Obligation {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
source: string
|
||||
source_article: string
|
||||
deadline: string | null
|
||||
status: 'pending' | 'in-progress' | 'completed' | 'overdue'
|
||||
priority: 'critical' | 'high' | 'medium' | 'low'
|
||||
responsible: string
|
||||
linked_systems: string[]
|
||||
linked_vendor_ids?: string[]
|
||||
assessment_id?: string
|
||||
rule_code?: string
|
||||
notes?: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
evidence?: string[]
|
||||
review_date?: string
|
||||
category?: string
|
||||
}
|
||||
|
||||
export type ObligationComplianceIssueType =
|
||||
| 'MISSING_RESPONSIBLE'
|
||||
| 'OVERDUE_DEADLINE'
|
||||
| 'MISSING_EVIDENCE'
|
||||
| 'MISSING_DESCRIPTION'
|
||||
| 'NO_LEGAL_REFERENCE'
|
||||
| 'INCOMPLETE_REGULATION'
|
||||
| 'HIGH_PRIORITY_NOT_STARTED'
|
||||
| 'STALE_PENDING'
|
||||
| 'MISSING_LINKED_SYSTEMS'
|
||||
| 'NO_REVIEW_PROCESS'
|
||||
| 'CRITICAL_WITHOUT_EVIDENCE'
|
||||
| 'MISSING_VENDOR_LINK'
|
||||
|
||||
export type ObligationComplianceIssueSeverity = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW'
|
||||
|
||||
export interface ObligationComplianceIssue {
|
||||
type: ObligationComplianceIssueType
|
||||
severity: ObligationComplianceIssueSeverity
|
||||
message: string
|
||||
affectedObligations: string[]
|
||||
recommendation: string
|
||||
}
|
||||
|
||||
export interface ObligationComplianceCheckResult {
|
||||
score: number
|
||||
issues: ObligationComplianceIssue[]
|
||||
summary: { total: number; critical: number; high: number; medium: number; low: number }
|
||||
checkedAt: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CONSTANTS
|
||||
// =============================================================================
|
||||
|
||||
export const OBLIGATION_SEVERITY_LABELS_DE: Record<ObligationComplianceIssueSeverity, string> = {
|
||||
CRITICAL: 'Kritisch',
|
||||
HIGH: 'Hoch',
|
||||
MEDIUM: 'Mittel',
|
||||
LOW: 'Niedrig',
|
||||
}
|
||||
|
||||
export const OBLIGATION_SEVERITY_COLORS: Record<ObligationComplianceIssueSeverity, string> = {
|
||||
CRITICAL: '#dc2626',
|
||||
HIGH: '#ea580c',
|
||||
MEDIUM: '#d97706',
|
||||
LOW: '#6b7280',
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HELPERS
|
||||
// =============================================================================
|
||||
|
||||
function daysBetween(date: Date, now: Date): number {
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
return Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PER-OBLIGATION CHECKS (1-5, 9, 11)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Check 1: MISSING_RESPONSIBLE (MEDIUM)
|
||||
* Pflicht ohne verantwortliche Person/Abteilung.
|
||||
*/
|
||||
function checkMissingResponsible(obligations: Obligation[]): ObligationComplianceIssue | null {
|
||||
const affected = obligations.filter(o => !o.responsible || o.responsible.trim() === '')
|
||||
if (affected.length === 0) return null
|
||||
|
||||
return {
|
||||
type: 'MISSING_RESPONSIBLE',
|
||||
severity: 'MEDIUM',
|
||||
message: `${affected.length} Pflicht(en) ohne verantwortliche Person oder Abteilung. Ohne klare Zustaendigkeit koennen Pflichten nicht zuverlaessig umgesetzt werden.`,
|
||||
affectedObligations: affected.map(o => o.id),
|
||||
recommendation: 'Weisen Sie jeder Pflicht eine verantwortliche Person oder Abteilung zu.',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 2: OVERDUE_DEADLINE (HIGH)
|
||||
* Pflicht mit Deadline in der Vergangenheit + Status != completed.
|
||||
*/
|
||||
function checkOverdueDeadline(obligations: Obligation[]): ObligationComplianceIssue | null {
|
||||
const now = new Date()
|
||||
const affected = obligations.filter(o => {
|
||||
if (!o.deadline || o.status === 'completed') return false
|
||||
return new Date(o.deadline) < now
|
||||
})
|
||||
if (affected.length === 0) return null
|
||||
|
||||
return {
|
||||
type: 'OVERDUE_DEADLINE',
|
||||
severity: 'HIGH',
|
||||
message: `${affected.length} Pflicht(en) mit ueberschrittener Frist. Ueberfaellige Pflichten stellen ein Compliance-Risiko dar und koennen zu Bussgeldern fuehren.`,
|
||||
affectedObligations: affected.map(o => o.id),
|
||||
recommendation: 'Bearbeiten Sie ueberfaellige Pflichten umgehend oder passen Sie die Fristen an.',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 3: MISSING_EVIDENCE (HIGH)
|
||||
* Completed-Pflicht ohne Evidence.
|
||||
*/
|
||||
function checkMissingEvidence(obligations: Obligation[]): ObligationComplianceIssue | null {
|
||||
const affected = obligations.filter(o =>
|
||||
o.status === 'completed' && (!o.evidence || o.evidence.length === 0)
|
||||
)
|
||||
if (affected.length === 0) return null
|
||||
|
||||
return {
|
||||
type: 'MISSING_EVIDENCE',
|
||||
severity: 'HIGH',
|
||||
message: `${affected.length} abgeschlossene Pflicht(en) ohne Nachweis. Ohne Nachweise ist die Erfuellung im Audit nicht belegbar.`,
|
||||
affectedObligations: affected.map(o => o.id),
|
||||
recommendation: 'Hinterlegen Sie Nachweisdokumente fuer alle abgeschlossenen Pflichten.',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 4: MISSING_DESCRIPTION (MEDIUM)
|
||||
* Pflicht ohne Beschreibung.
|
||||
*/
|
||||
function checkMissingDescription(obligations: Obligation[]): ObligationComplianceIssue | null {
|
||||
const affected = obligations.filter(o => !o.description || o.description.trim() === '')
|
||||
if (affected.length === 0) return null
|
||||
|
||||
return {
|
||||
type: 'MISSING_DESCRIPTION',
|
||||
severity: 'MEDIUM',
|
||||
message: `${affected.length} Pflicht(en) ohne Beschreibung. Eine fehlende Beschreibung erschwert die Nachvollziehbarkeit und Umsetzung.`,
|
||||
affectedObligations: affected.map(o => o.id),
|
||||
recommendation: 'Ergaenzen Sie eine Beschreibung fuer jede Pflicht, die den Inhalt und die Anforderungen erlaeutert.',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 5: NO_LEGAL_REFERENCE (HIGH)
|
||||
* Pflicht ohne source_article (kein Artikel-Bezug).
|
||||
*/
|
||||
function checkNoLegalReference(obligations: Obligation[]): ObligationComplianceIssue | null {
|
||||
const affected = obligations.filter(o => !o.source_article || o.source_article.trim() === '')
|
||||
if (affected.length === 0) return null
|
||||
|
||||
return {
|
||||
type: 'NO_LEGAL_REFERENCE',
|
||||
severity: 'HIGH',
|
||||
message: `${affected.length} Pflicht(en) ohne Artikel-/Paragraphen-Referenz. Ohne Rechtsbezug ist die Pflicht im Audit nicht nachvollziehbar.`,
|
||||
affectedObligations: affected.map(o => o.id),
|
||||
recommendation: 'Ergaenzen Sie die Rechtsgrundlage (z.B. Art. 32 DSGVO) fuer jede Pflicht.',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 9: MISSING_LINKED_SYSTEMS (MEDIUM)
|
||||
* Pflicht ohne verknuepfte Systeme/Verarbeitungen.
|
||||
*/
|
||||
function checkMissingLinkedSystems(obligations: Obligation[]): ObligationComplianceIssue | null {
|
||||
const affected = obligations.filter(o => !o.linked_systems || o.linked_systems.length === 0)
|
||||
if (affected.length === 0) return null
|
||||
|
||||
return {
|
||||
type: 'MISSING_LINKED_SYSTEMS',
|
||||
severity: 'MEDIUM',
|
||||
message: `${affected.length} Pflicht(en) ohne verknuepfte Systeme oder Verarbeitungstaetigkeiten. Ohne Systemzuordnung fehlt der operative Bezug.`,
|
||||
affectedObligations: affected.map(o => o.id),
|
||||
recommendation: 'Ordnen Sie jeder Pflicht die betroffenen IT-Systeme oder Verarbeitungstaetigkeiten zu.',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 11: CRITICAL_WITHOUT_EVIDENCE (CRITICAL)
|
||||
* Critical-Pflicht ohne Evidence.
|
||||
*/
|
||||
function checkCriticalWithoutEvidence(obligations: Obligation[]): ObligationComplianceIssue | null {
|
||||
const affected = obligations.filter(o =>
|
||||
o.priority === 'critical' && (!o.evidence || o.evidence.length === 0)
|
||||
)
|
||||
if (affected.length === 0) return null
|
||||
|
||||
return {
|
||||
type: 'CRITICAL_WITHOUT_EVIDENCE',
|
||||
severity: 'CRITICAL',
|
||||
message: `${affected.length} kritische Pflicht(en) ohne Nachweis. Kritische Pflichten erfordern zwingend eine Dokumentation der Erfuellung.`,
|
||||
affectedObligations: affected.map(o => o.id),
|
||||
recommendation: 'Hinterlegen Sie umgehend Nachweise fuer alle kritischen Pflichten.',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 12: MISSING_VENDOR_LINK (MEDIUM)
|
||||
* Art.-28-Pflicht ohne verknuepften Auftragsverarbeiter.
|
||||
*/
|
||||
function checkMissingVendorLink(obligations: Obligation[]): ObligationComplianceIssue | null {
|
||||
const affected = obligations.filter(o =>
|
||||
o.source_article?.includes('Art. 28') &&
|
||||
(!o.linked_vendor_ids || o.linked_vendor_ids.length === 0)
|
||||
)
|
||||
if (affected.length === 0) return null
|
||||
return {
|
||||
type: 'MISSING_VENDOR_LINK',
|
||||
severity: 'MEDIUM',
|
||||
message: `${affected.length} Art.-28-Pflicht(en) ohne verknuepften Auftragsverarbeiter.`,
|
||||
affectedObligations: affected.map(o => o.id),
|
||||
recommendation: 'Verknuepfen Sie Art.-28-Pflichten mit den betroffenen Auftragsverarbeitern im Vendor Register.',
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// AGGREGATE CHECKS (6-8, 10)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Check 6: INCOMPLETE_REGULATION (HIGH)
|
||||
* Regulierung, bei der alle Pflichten pending/overdue sind.
|
||||
*/
|
||||
function checkIncompleteRegulation(obligations: Obligation[]): ObligationComplianceIssue | null {
|
||||
const bySource = new Map<string, Obligation[]>()
|
||||
for (const o of obligations) {
|
||||
const src = o.source || 'Unbekannt'
|
||||
if (!bySource.has(src)) bySource.set(src, [])
|
||||
bySource.get(src)!.push(o)
|
||||
}
|
||||
|
||||
const incompleteRegs: string[] = []
|
||||
const affectedIds: string[] = []
|
||||
|
||||
for (const [source, obls] of bySource.entries()) {
|
||||
if (obls.length < 2) continue // Skip single-obligation regulations
|
||||
const allStalled = obls.every(o => o.status === 'pending' || o.status === 'overdue')
|
||||
if (allStalled) {
|
||||
incompleteRegs.push(source)
|
||||
affectedIds.push(...obls.map(o => o.id))
|
||||
}
|
||||
}
|
||||
|
||||
if (incompleteRegs.length === 0) return null
|
||||
|
||||
return {
|
||||
type: 'INCOMPLETE_REGULATION',
|
||||
severity: 'HIGH',
|
||||
message: `${incompleteRegs.length} Regulierung(en) vollstaendig ohne Umsetzung: ${incompleteRegs.join(', ')}. Alle Pflichten sind ausstehend oder ueberfaellig.`,
|
||||
affectedObligations: affectedIds,
|
||||
recommendation: 'Beginnen Sie mit der Umsetzung der wichtigsten Pflichten in den betroffenen Regulierungen.',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 7: HIGH_PRIORITY_NOT_STARTED (CRITICAL)
|
||||
* Critical/High-Pflicht seit > 30 Tagen pending.
|
||||
*/
|
||||
function checkHighPriorityNotStarted(obligations: Obligation[]): ObligationComplianceIssue | null {
|
||||
const now = new Date()
|
||||
const affected = obligations.filter(o => {
|
||||
if (o.status !== 'pending') return false
|
||||
if (o.priority !== 'critical' && o.priority !== 'high') return false
|
||||
if (!o.created_at) return false
|
||||
return daysBetween(new Date(o.created_at), now) > 30
|
||||
})
|
||||
|
||||
if (affected.length === 0) return null
|
||||
|
||||
return {
|
||||
type: 'HIGH_PRIORITY_NOT_STARTED',
|
||||
severity: 'CRITICAL',
|
||||
message: `${affected.length} hochprioritaere Pflicht(en) seit ueber 30 Tagen nicht begonnen. Dies deutet auf organisatorische Blockaden oder fehlende Priorisierung hin.`,
|
||||
affectedObligations: affected.map(o => o.id),
|
||||
recommendation: 'Starten Sie umgehend mit der Bearbeitung dieser kritischen/hohen Pflichten und erstellen Sie einen Umsetzungsplan.',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 8: STALE_PENDING (LOW)
|
||||
* Pflicht seit > 90 Tagen pending.
|
||||
*/
|
||||
function checkStalePending(obligations: Obligation[]): ObligationComplianceIssue | null {
|
||||
const now = new Date()
|
||||
const affected = obligations.filter(o => {
|
||||
if (o.status !== 'pending') return false
|
||||
if (!o.created_at) return false
|
||||
return daysBetween(new Date(o.created_at), now) > 90
|
||||
})
|
||||
|
||||
if (affected.length === 0) return null
|
||||
|
||||
return {
|
||||
type: 'STALE_PENDING',
|
||||
severity: 'LOW',
|
||||
message: `${affected.length} Pflicht(en) seit ueber 90 Tagen ausstehend. Langfristig unbearbeitete Pflichten sollten priorisiert oder als nicht relevant markiert werden.`,
|
||||
affectedObligations: affected.map(o => o.id),
|
||||
recommendation: 'Pruefen Sie, ob die Pflichten weiterhin relevant sind, und setzen Sie Prioritaeten fuer die Umsetzung.',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 10: NO_REVIEW_PROCESS (MEDIUM)
|
||||
* Keine einzige Pflicht hat review_date.
|
||||
*/
|
||||
function checkNoReviewProcess(obligations: Obligation[]): ObligationComplianceIssue | null {
|
||||
if (obligations.length === 0) return null
|
||||
const hasAnyReview = obligations.some(o => o.review_date)
|
||||
if (hasAnyReview) return null
|
||||
|
||||
return {
|
||||
type: 'NO_REVIEW_PROCESS',
|
||||
severity: 'MEDIUM',
|
||||
message: 'Keine Pflicht hat ein Pruefungsdatum (review_date). Ohne regelmaessige Ueberpruefung ist die Aktualitaet des Pflichtenregisters nicht gewaehrleistet.',
|
||||
affectedObligations: [],
|
||||
recommendation: 'Fuehren Sie ein Pruefintervall ein und setzen Sie review_date fuer alle Pflichten.',
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN COMPLIANCE CHECK
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Fuehrt einen vollstaendigen Compliance-Check ueber alle Pflichten durch.
|
||||
*/
|
||||
export function runObligationComplianceCheck(obligations: Obligation[]): ObligationComplianceCheckResult {
|
||||
const issues: ObligationComplianceIssue[] = []
|
||||
|
||||
const checks = [
|
||||
checkMissingResponsible(obligations),
|
||||
checkOverdueDeadline(obligations),
|
||||
checkMissingEvidence(obligations),
|
||||
checkMissingDescription(obligations),
|
||||
checkNoLegalReference(obligations),
|
||||
checkIncompleteRegulation(obligations),
|
||||
checkHighPriorityNotStarted(obligations),
|
||||
checkStalePending(obligations),
|
||||
checkMissingLinkedSystems(obligations),
|
||||
checkNoReviewProcess(obligations),
|
||||
checkCriticalWithoutEvidence(obligations),
|
||||
checkMissingVendorLink(obligations),
|
||||
]
|
||||
|
||||
for (const issue of checks) {
|
||||
if (issue !== null) {
|
||||
issues.push(issue)
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate score
|
||||
const summary = { total: issues.length, critical: 0, high: 0, medium: 0, low: 0 }
|
||||
for (const issue of issues) {
|
||||
switch (issue.severity) {
|
||||
case 'CRITICAL': summary.critical++; break
|
||||
case 'HIGH': summary.high++; break
|
||||
case 'MEDIUM': summary.medium++; break
|
||||
case 'LOW': summary.low++; break
|
||||
}
|
||||
}
|
||||
|
||||
const rawScore = 100 - (summary.critical * 15 + summary.high * 10 + summary.medium * 5 + summary.low * 2)
|
||||
const score = Math.max(0, rawScore)
|
||||
|
||||
return {
|
||||
score,
|
||||
issues,
|
||||
summary,
|
||||
checkedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
914
admin-compliance/lib/sdk/obligations-document.ts
Normal file
914
admin-compliance/lib/sdk/obligations-document.ts
Normal file
@@ -0,0 +1,914 @@
|
||||
// =============================================================================
|
||||
// Obligations Module - Pflichtenregister Document Generator
|
||||
// Generates a printable, audit-ready HTML document for the obligation register
|
||||
// =============================================================================
|
||||
|
||||
import type { Obligation, ObligationComplianceCheckResult, ObligationComplianceIssueSeverity } from './obligations-compliance'
|
||||
import { OBLIGATION_SEVERITY_LABELS_DE, OBLIGATION_SEVERITY_COLORS } from './obligations-compliance'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface ObligationDocumentOrgHeader {
|
||||
organizationName: string
|
||||
industry: string
|
||||
dpoName: string
|
||||
dpoContact: string
|
||||
responsiblePerson: string
|
||||
legalDepartment: string
|
||||
documentVersion: string
|
||||
lastReviewDate: string
|
||||
nextReviewDate: string
|
||||
reviewInterval: string
|
||||
}
|
||||
|
||||
export interface ObligationDocumentRevision {
|
||||
version: string
|
||||
date: string
|
||||
author: string
|
||||
changes: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// DEFAULTS
|
||||
// =============================================================================
|
||||
|
||||
export function createDefaultObligationDocumentOrgHeader(): ObligationDocumentOrgHeader {
|
||||
const now = new Date()
|
||||
const nextYear = new Date()
|
||||
nextYear.setFullYear(nextYear.getFullYear() + 1)
|
||||
|
||||
return {
|
||||
organizationName: '',
|
||||
industry: '',
|
||||
dpoName: '',
|
||||
dpoContact: '',
|
||||
responsiblePerson: '',
|
||||
legalDepartment: '',
|
||||
documentVersion: '1.0',
|
||||
lastReviewDate: now.toISOString().split('T')[0],
|
||||
nextReviewDate: nextYear.toISOString().split('T')[0],
|
||||
reviewInterval: 'Jaehrlich',
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STATUS & PRIORITY LABELS
|
||||
// =============================================================================
|
||||
|
||||
const STATUS_LABELS_DE: Record<string, string> = {
|
||||
'pending': 'Ausstehend',
|
||||
'in-progress': 'In Bearbeitung',
|
||||
'completed': 'Abgeschlossen',
|
||||
'overdue': 'Ueberfaellig',
|
||||
}
|
||||
|
||||
const STATUS_BADGE_CLASSES: Record<string, string> = {
|
||||
'pending': 'badge-draft',
|
||||
'in-progress': 'badge-review',
|
||||
'completed': 'badge-active',
|
||||
'overdue': 'badge-critical',
|
||||
}
|
||||
|
||||
const PRIORITY_LABELS_DE: Record<string, string> = {
|
||||
critical: 'Kritisch',
|
||||
high: 'Hoch',
|
||||
medium: 'Mittel',
|
||||
low: 'Niedrig',
|
||||
}
|
||||
|
||||
const PRIORITY_BADGE_CLASSES: Record<string, string> = {
|
||||
critical: 'badge-critical',
|
||||
high: 'badge-high',
|
||||
medium: 'badge-medium',
|
||||
low: 'badge-low',
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HTML DOCUMENT BUILDER
|
||||
// =============================================================================
|
||||
|
||||
export function buildObligationDocumentHtml(
|
||||
obligations: Obligation[],
|
||||
orgHeader: ObligationDocumentOrgHeader,
|
||||
complianceResult: ObligationComplianceCheckResult | null,
|
||||
revisions: ObligationDocumentRevision[]
|
||||
): string {
|
||||
const today = new Date().toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
})
|
||||
|
||||
const orgName = orgHeader.organizationName || 'Organisation'
|
||||
|
||||
// Group obligations by source (regulation)
|
||||
const bySource = new Map<string, Obligation[]>()
|
||||
for (const o of obligations) {
|
||||
const src = o.source || 'Sonstig'
|
||||
if (!bySource.has(src)) bySource.set(src, [])
|
||||
bySource.get(src)!.push(o)
|
||||
}
|
||||
|
||||
// Build role map
|
||||
const roleMap = new Map<string, Obligation[]>()
|
||||
for (const o of obligations) {
|
||||
const role = o.responsible || 'Nicht zugewiesen'
|
||||
if (!roleMap.has(role)) roleMap.set(role, [])
|
||||
roleMap.get(role)!.push(o)
|
||||
}
|
||||
|
||||
// Distinct sources
|
||||
const distinctSources = Array.from(bySource.keys()).sort()
|
||||
|
||||
// =========================================================================
|
||||
// HTML Template
|
||||
// =========================================================================
|
||||
|
||||
let html = `<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Pflichtenregister — ${escHtml(orgName)}</title>
|
||||
<style>
|
||||
@page { size: A4; margin: 20mm 18mm 22mm 18mm; }
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
font-size: 10pt;
|
||||
line-height: 1.5;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
/* Cover */
|
||||
.cover {
|
||||
min-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
page-break-after: always;
|
||||
}
|
||||
.cover h1 {
|
||||
font-size: 28pt;
|
||||
color: #5b21b6;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.cover .subtitle {
|
||||
font-size: 14pt;
|
||||
color: #7c3aed;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
.cover .org-info {
|
||||
background: #f5f3ff;
|
||||
border: 1px solid #ddd6fe;
|
||||
border-radius: 8px;
|
||||
padding: 24px 40px;
|
||||
text-align: left;
|
||||
width: 400px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.cover .org-info div { margin-bottom: 6px; }
|
||||
.cover .org-info .label { font-weight: 600; color: #5b21b6; display: inline-block; min-width: 160px; }
|
||||
.cover .legal-ref {
|
||||
font-size: 9pt;
|
||||
color: #64748b;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* TOC */
|
||||
.toc {
|
||||
page-break-after: always;
|
||||
padding-top: 40px;
|
||||
}
|
||||
.toc h2 {
|
||||
font-size: 18pt;
|
||||
color: #5b21b6;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 2px solid #5b21b6;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.toc-entry {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px dotted #cbd5e1;
|
||||
font-size: 10pt;
|
||||
}
|
||||
.toc-entry .toc-num { font-weight: 600; color: #5b21b6; min-width: 40px; }
|
||||
|
||||
/* Sections */
|
||||
.section {
|
||||
page-break-inside: avoid;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.section-header {
|
||||
font-size: 14pt;
|
||||
color: #5b21b6;
|
||||
font-weight: 700;
|
||||
margin: 30px 0 12px 0;
|
||||
border-bottom: 2px solid #ddd6fe;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
.section-body { margin-bottom: 16px; }
|
||||
|
||||
/* Tables */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 10px 0 16px 0;
|
||||
font-size: 9pt;
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
th {
|
||||
background: #f5f3ff;
|
||||
color: #5b21b6;
|
||||
font-weight: 600;
|
||||
font-size: 8.5pt;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
tr:nth-child(even) td { background: #faf5ff; }
|
||||
|
||||
/* Detail cards */
|
||||
.policy-detail {
|
||||
page-break-inside: avoid;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.policy-detail-header {
|
||||
background: #f5f3ff;
|
||||
padding: 8px 12px;
|
||||
font-weight: 700;
|
||||
color: #5b21b6;
|
||||
border-bottom: 1px solid #ddd6fe;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.policy-detail-body { padding: 0; }
|
||||
.policy-detail-body table { margin: 0; }
|
||||
.policy-detail-body th { width: 200px; }
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border-radius: 9999px;
|
||||
font-size: 8pt;
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge-active { background: #dcfce7; color: #166534; }
|
||||
.badge-draft { background: #f3f4f6; color: #374151; }
|
||||
.badge-review { background: #fef9c3; color: #854d0e; }
|
||||
.badge-critical { background: #fecaca; color: #991b1b; }
|
||||
.badge-high { background: #fed7aa; color: #9a3412; }
|
||||
.badge-medium { background: #fef3c7; color: #92400e; }
|
||||
.badge-low { background: #f3f4f6; color: #4b5563; }
|
||||
|
||||
/* Principles */
|
||||
.principle {
|
||||
margin-bottom: 10px;
|
||||
padding-left: 20px;
|
||||
position: relative;
|
||||
}
|
||||
.principle::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 6px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #7c3aed;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.principle strong { color: #5b21b6; }
|
||||
|
||||
/* Score */
|
||||
.score-box {
|
||||
display: inline-block;
|
||||
padding: 4px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 18pt;
|
||||
font-weight: 700;
|
||||
margin-right: 12px;
|
||||
}
|
||||
.score-excellent { background: #dcfce7; color: #166534; }
|
||||
.score-good { background: #dbeafe; color: #1e40af; }
|
||||
.score-needs-work { background: #fef3c7; color: #92400e; }
|
||||
.score-poor { background: #fecaca; color: #991b1b; }
|
||||
|
||||
/* Footer */
|
||||
.page-footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 8px 18mm;
|
||||
font-size: 7.5pt;
|
||||
color: #94a3b8;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
/* Print */
|
||||
@media print {
|
||||
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
.no-print { display: none !important; }
|
||||
.page-break { page-break-after: always; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 0: Cover Page
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="cover">
|
||||
<h1>Pflichtenregister</h1>
|
||||
<div class="subtitle">Regulatorische Pflichten — DSGVO, AI Act, NIS2 und weitere</div>
|
||||
<div class="org-info">
|
||||
<div><span class="label">Organisation:</span> ${escHtml(orgName)}</div>
|
||||
${orgHeader.industry ? `<div><span class="label">Branche:</span> ${escHtml(orgHeader.industry)}</div>` : ''}
|
||||
${orgHeader.dpoName ? `<div><span class="label">DSB:</span> ${escHtml(orgHeader.dpoName)}</div>` : ''}
|
||||
${orgHeader.dpoContact ? `<div><span class="label">DSB-Kontakt:</span> ${escHtml(orgHeader.dpoContact)}</div>` : ''}
|
||||
${orgHeader.responsiblePerson ? `<div><span class="label">Verantwortlicher:</span> ${escHtml(orgHeader.responsiblePerson)}</div>` : ''}
|
||||
${orgHeader.legalDepartment ? `<div><span class="label">Rechtsabteilung:</span> ${escHtml(orgHeader.legalDepartment)}</div>` : ''}
|
||||
</div>
|
||||
<div class="legal-ref">
|
||||
Version ${escHtml(orgHeader.documentVersion)} | Stand: ${today}<br/>
|
||||
Letzte Pruefung: ${formatDateDE(orgHeader.lastReviewDate)} | Naechste Pruefung: ${formatDateDE(orgHeader.nextReviewDate)}<br/>
|
||||
Pruefintervall: ${escHtml(orgHeader.reviewInterval)}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Table of Contents
|
||||
// =========================================================================
|
||||
const sections = [
|
||||
'Ziel und Zweck',
|
||||
'Geltungsbereich',
|
||||
'Methodik',
|
||||
'Regulatorische Grundlagen',
|
||||
'Pflichtenuebersicht',
|
||||
'Detaillierte Pflichten',
|
||||
'Verantwortlichkeiten',
|
||||
'Fristen und Termine',
|
||||
'Nachweisverzeichnis',
|
||||
'Compliance-Status',
|
||||
'Aenderungshistorie',
|
||||
]
|
||||
|
||||
html += `
|
||||
<div class="toc">
|
||||
<h2>Inhaltsverzeichnis</h2>
|
||||
${sections.map((s, i) => `<div class="toc-entry"><span><span class="toc-num">${i + 1}.</span> ${escHtml(s)}</span></div>`).join('\n ')}
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 1: Ziel und Zweck
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">1. Ziel und Zweck</div>
|
||||
<div class="section-body">
|
||||
<p>Dieses Pflichtenregister dokumentiert alle regulatorischen Pflichten, denen
|
||||
<strong>${escHtml(orgName)}</strong> unterliegt. Es dient der systematischen Erfassung,
|
||||
Ueberwachung und Nachverfolgung aller Compliance-Anforderungen aus den anwendbaren
|
||||
Regulierungen.</p>
|
||||
<p style="margin-top: 8px;">Das Register erfuellt folgende Zwecke:</p>
|
||||
<ul style="margin: 8px 0 8px 24px;">
|
||||
<li>Vollstaendige Erfassung aller anwendbaren regulatorischen Pflichten</li>
|
||||
<li>Zuordnung von Verantwortlichkeiten und Fristen</li>
|
||||
<li>Nachverfolgung des Umsetzungsstatus</li>
|
||||
<li>Dokumentation von Nachweisen fuer Audits</li>
|
||||
<li>Identifikation von Compliance-Luecken und Handlungsbedarf</li>
|
||||
</ul>
|
||||
<table>
|
||||
<tr><th>Rechtsrahmen</th><th>Relevanz</th></tr>
|
||||
<tr><td><strong>DSGVO (EU) 2016/679</strong></td><td>Datenschutz-Grundverordnung — Kernregulierung fuer personenbezogene Daten</td></tr>
|
||||
<tr><td><strong>AI Act (EU) 2024/1689</strong></td><td>KI-Verordnung — Anforderungen an KI-Systeme nach Risikoklasse</td></tr>
|
||||
<tr><td><strong>NIS2 (EU) 2022/2555</strong></td><td>Netzwerk- und Informationssicherheit — Cybersicherheitspflichten</td></tr>
|
||||
<tr><td><strong>BDSG</strong></td><td>Bundesdatenschutzgesetz — Nationale Ergaenzung zur DSGVO</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 2: Geltungsbereich
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">2. Geltungsbereich</div>
|
||||
<div class="section-body">
|
||||
<p>Dieses Pflichtenregister gilt fuer alle Geschaeftsprozesse und IT-Systeme von
|
||||
<strong>${escHtml(orgName)}</strong>${orgHeader.industry ? ` (Branche: ${escHtml(orgHeader.industry)})` : ''}.</p>
|
||||
<p style="margin-top: 8px;">Anwendbare Regulierungen:</p>
|
||||
<table>
|
||||
<tr><th>Regulierung</th><th>Anzahl Pflichten</th><th>Status</th></tr>
|
||||
`
|
||||
for (const [source, obls] of bySource.entries()) {
|
||||
const completed = obls.filter(o => o.status === 'completed').length
|
||||
const pct = obls.length > 0 ? Math.round((completed / obls.length) * 100) : 0
|
||||
html += ` <tr>
|
||||
<td>${escHtml(source)}</td>
|
||||
<td>${obls.length}</td>
|
||||
<td>${completed}/${obls.length} abgeschlossen (${pct}%)</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
html += ` </table>
|
||||
<p>Insgesamt umfasst dieses Register <strong>${obligations.length}</strong> Pflichten aus
|
||||
<strong>${distinctSources.length}</strong> Regulierungen.</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 3: Methodik
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">3. Methodik</div>
|
||||
<div class="section-body">
|
||||
<p>Die Identifikation und Bewertung der Pflichten erfolgt in drei Schritten:</p>
|
||||
<div class="principle"><strong>Pflicht-Identifikation:</strong> Systematische Analyse aller anwendbaren Regulierungen und Extraktion der einzelnen Pflichten mit Artikel-Referenz, Beschreibung und Zielgruppe.</div>
|
||||
<div class="principle"><strong>Bewertung und Priorisierung:</strong> Jede Pflicht wird nach Prioritaet (kritisch, hoch, mittel, niedrig) und Dringlichkeit (Frist) bewertet. Die Bewertung basiert auf dem Risikopotenzial bei Nichterfuellung.</div>
|
||||
<div class="principle"><strong>Ueberwachung und Nachverfolgung:</strong> Regelmaessige Pruefung des Umsetzungsstatus, Aktualisierung der Fristen und Dokumentation von Nachweisen.</div>
|
||||
<p style="margin-top: 12px;">Die Pflichten werden ueber einen automatisierten Compliance-Check geprueft, der
|
||||
11 Kriterien umfasst (siehe Abschnitt 10: Compliance-Status).</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 4: Regulatorische Grundlagen
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section page-break">
|
||||
<div class="section-header">4. Regulatorische Grundlagen</div>
|
||||
<div class="section-body">
|
||||
<p>Die folgende Tabelle zeigt die regulatorischen Grundlagen mit Artikelzahl und Umsetzungsstatus:</p>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Regulierung</th>
|
||||
<th>Pflichten</th>
|
||||
<th>Kritisch</th>
|
||||
<th>Hoch</th>
|
||||
<th>Mittel</th>
|
||||
<th>Niedrig</th>
|
||||
<th>Abgeschlossen</th>
|
||||
</tr>
|
||||
`
|
||||
for (const [source, obls] of bySource.entries()) {
|
||||
const critical = obls.filter(o => o.priority === 'critical').length
|
||||
const high = obls.filter(o => o.priority === 'high').length
|
||||
const medium = obls.filter(o => o.priority === 'medium').length
|
||||
const low = obls.filter(o => o.priority === 'low').length
|
||||
const completed = obls.filter(o => o.status === 'completed').length
|
||||
|
||||
html += ` <tr>
|
||||
<td><strong>${escHtml(source)}</strong></td>
|
||||
<td>${obls.length}</td>
|
||||
<td>${critical}</td>
|
||||
<td>${high}</td>
|
||||
<td>${medium}</td>
|
||||
<td>${low}</td>
|
||||
<td>${completed}</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
|
||||
// Totals row
|
||||
const totalCritical = obligations.filter(o => o.priority === 'critical').length
|
||||
const totalHigh = obligations.filter(o => o.priority === 'high').length
|
||||
const totalMedium = obligations.filter(o => o.priority === 'medium').length
|
||||
const totalLow = obligations.filter(o => o.priority === 'low').length
|
||||
const totalCompleted = obligations.filter(o => o.status === 'completed').length
|
||||
|
||||
html += ` <tr style="font-weight: 700; background: #f5f3ff;">
|
||||
<td>Gesamt</td>
|
||||
<td>${obligations.length}</td>
|
||||
<td>${totalCritical}</td>
|
||||
<td>${totalHigh}</td>
|
||||
<td>${totalMedium}</td>
|
||||
<td>${totalLow}</td>
|
||||
<td>${totalCompleted}</td>
|
||||
</tr>
|
||||
`
|
||||
|
||||
html += ` </table>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 5: Pflichtenuebersicht
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">5. Pflichtenuebersicht</div>
|
||||
<div class="section-body">
|
||||
<p>Uebersicht aller ${obligations.length} Pflichten nach Regulierung und Status:</p>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Regulierung</th>
|
||||
<th>Gesamt</th>
|
||||
<th>Ausstehend</th>
|
||||
<th>In Bearbeitung</th>
|
||||
<th>Abgeschlossen</th>
|
||||
<th>Ueberfaellig</th>
|
||||
</tr>
|
||||
`
|
||||
for (const [source, obls] of bySource.entries()) {
|
||||
const pending = obls.filter(o => o.status === 'pending').length
|
||||
const inProgress = obls.filter(o => o.status === 'in-progress').length
|
||||
const completed = obls.filter(o => o.status === 'completed').length
|
||||
const overdue = obls.filter(o => o.status === 'overdue').length
|
||||
|
||||
html += ` <tr>
|
||||
<td>${escHtml(source)}</td>
|
||||
<td>${obls.length}</td>
|
||||
<td>${pending}</td>
|
||||
<td>${inProgress}</td>
|
||||
<td>${completed}</td>
|
||||
<td>${overdue > 0 ? `<span class="badge badge-critical">${overdue}</span>` : '0'}</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
|
||||
html += ` </table>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 6: Detaillierte Pflichten
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">6. Detaillierte Pflichten</div>
|
||||
<div class="section-body">
|
||||
`
|
||||
|
||||
for (const [source, obls] of bySource.entries()) {
|
||||
// Sort by priority (critical first) then by title
|
||||
const priorityOrder: Record<string, number> = { critical: 0, high: 1, medium: 2, low: 3 }
|
||||
const sorted = [...obls].sort((a, b) => {
|
||||
const pa = priorityOrder[a.priority] ?? 2
|
||||
const pb = priorityOrder[b.priority] ?? 2
|
||||
if (pa !== pb) return pa - pb
|
||||
return a.title.localeCompare(b.title)
|
||||
})
|
||||
|
||||
html += ` <h3 style="color: #5b21b6; margin: 20px 0 10px 0; font-size: 11pt;">${escHtml(source)} <span style="font-weight: 400; font-size: 9pt; color: #64748b;">(${sorted.length} Pflichten)</span></h3>
|
||||
`
|
||||
|
||||
for (const o of sorted) {
|
||||
const statusLabel = STATUS_LABELS_DE[o.status] || o.status
|
||||
const statusBadge = STATUS_BADGE_CLASSES[o.status] || 'badge-draft'
|
||||
const priorityLabel = PRIORITY_LABELS_DE[o.priority] || o.priority
|
||||
const priorityBadge = PRIORITY_BADGE_CLASSES[o.priority] || 'badge-draft'
|
||||
const deadlineStr = o.deadline ? formatDateDE(o.deadline) : '—'
|
||||
const evidenceStr = o.evidence && o.evidence.length > 0
|
||||
? o.evidence.map(e => escHtml(e)).join(', ')
|
||||
: '<em style="color: #d97706;">Kein Nachweis</em>'
|
||||
const systemsStr = o.linked_systems && o.linked_systems.length > 0
|
||||
? o.linked_systems.map(s => escHtml(s)).join(', ')
|
||||
: '—'
|
||||
|
||||
html += `
|
||||
<div class="policy-detail">
|
||||
<div class="policy-detail-header">
|
||||
<span>${escHtml(o.title)}</span>
|
||||
<span class="badge ${statusBadge}">${escHtml(statusLabel)}</span>
|
||||
</div>
|
||||
<div class="policy-detail-body">
|
||||
<table>
|
||||
<tr><th>Rechtsquelle</th><td>${escHtml(o.source)} ${escHtml(o.source_article || '')}</td></tr>
|
||||
<tr><th>Beschreibung</th><td>${escHtml(o.description || '—')}</td></tr>
|
||||
<tr><th>Prioritaet</th><td><span class="badge ${priorityBadge}">${escHtml(priorityLabel)}</span></td></tr>
|
||||
<tr><th>Status</th><td><span class="badge ${statusBadge}">${escHtml(statusLabel)}</span></td></tr>
|
||||
<tr><th>Verantwortlich</th><td>${escHtml(o.responsible || '—')}</td></tr>
|
||||
<tr><th>Frist</th><td>${deadlineStr}</td></tr>
|
||||
<tr><th>Nachweise</th><td>${evidenceStr}</td></tr>
|
||||
<tr><th>Betroffene Systeme</th><td>${systemsStr}</td></tr>
|
||||
${o.notes ? `<tr><th>Notizen</th><td>${escHtml(o.notes)}</td></tr>` : ''}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
html += ` </div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 7: Verantwortlichkeiten
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section page-break">
|
||||
<div class="section-header">7. Verantwortlichkeiten</div>
|
||||
<div class="section-body">
|
||||
<p>Die folgende Rollenmatrix zeigt, welche Personen oder Abteilungen fuer welche Pflichten
|
||||
die Umsetzungsverantwortung tragen:</p>
|
||||
<table>
|
||||
<tr><th>Verantwortlich</th><th>Pflichten</th><th>Anzahl</th><th>Davon offen</th></tr>
|
||||
`
|
||||
for (const [role, obls] of roleMap.entries()) {
|
||||
const openCount = obls.filter(o => o.status !== 'completed').length
|
||||
const titles = obls.slice(0, 5).map(o => escHtml(o.title))
|
||||
const suffix = obls.length > 5 ? `, ... (+${obls.length - 5})` : ''
|
||||
html += ` <tr>
|
||||
<td>${escHtml(role)}</td>
|
||||
<td>${titles.join('; ')}${suffix}</td>
|
||||
<td>${obls.length}</td>
|
||||
<td>${openCount}</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
|
||||
html += ` </table>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 8: Fristen und Termine
|
||||
// =========================================================================
|
||||
const now = new Date()
|
||||
const withDeadline = obligations
|
||||
.filter(o => o.deadline && o.status !== 'completed')
|
||||
.sort((a, b) => new Date(a.deadline!).getTime() - new Date(b.deadline!).getTime())
|
||||
|
||||
const overdue = withDeadline.filter(o => new Date(o.deadline!) < now)
|
||||
const upcoming = withDeadline.filter(o => new Date(o.deadline!) >= now)
|
||||
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">8. Fristen und Termine</div>
|
||||
<div class="section-body">
|
||||
`
|
||||
if (overdue.length > 0) {
|
||||
html += ` <h4 style="color: #dc2626; margin-bottom: 8px;">Ueberfaellige Pflichten (${overdue.length})</h4>
|
||||
<table>
|
||||
<tr><th>Pflicht</th><th>Regulierung</th><th>Frist</th><th>Tage ueberfaellig</th><th>Prioritaet</th></tr>
|
||||
`
|
||||
for (const o of overdue) {
|
||||
const days = daysBetween(new Date(o.deadline!), now)
|
||||
html += ` <tr>
|
||||
<td>${escHtml(o.title)}</td>
|
||||
<td>${escHtml(o.source)}</td>
|
||||
<td>${formatDateDE(o.deadline)}</td>
|
||||
<td><span class="badge badge-critical">${days} Tage</span></td>
|
||||
<td><span class="badge ${PRIORITY_BADGE_CLASSES[o.priority] || 'badge-draft'}">${escHtml(PRIORITY_LABELS_DE[o.priority] || o.priority)}</span></td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
html += ` </table>
|
||||
`
|
||||
}
|
||||
|
||||
if (upcoming.length > 0) {
|
||||
html += ` <h4 style="color: #5b21b6; margin: 16px 0 8px 0;">Anstehende Fristen (${upcoming.length})</h4>
|
||||
<table>
|
||||
<tr><th>Pflicht</th><th>Regulierung</th><th>Frist</th><th>Verbleibend</th><th>Verantwortlich</th></tr>
|
||||
`
|
||||
for (const o of upcoming.slice(0, 20)) {
|
||||
const days = daysBetween(now, new Date(o.deadline!))
|
||||
html += ` <tr>
|
||||
<td>${escHtml(o.title)}</td>
|
||||
<td>${escHtml(o.source)}</td>
|
||||
<td>${formatDateDE(o.deadline)}</td>
|
||||
<td>${days} Tage</td>
|
||||
<td>${escHtml(o.responsible || '—')}</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
if (upcoming.length > 20) {
|
||||
html += ` <tr><td colspan="5" style="text-align: center; color: #64748b;">... und ${upcoming.length - 20} weitere</td></tr>
|
||||
`
|
||||
}
|
||||
html += ` </table>
|
||||
`
|
||||
}
|
||||
|
||||
if (withDeadline.length === 0) {
|
||||
html += ` <p><em>Keine offenen Pflichten mit Fristen vorhanden.</em></p>
|
||||
`
|
||||
}
|
||||
|
||||
html += ` </div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 9: Nachweisverzeichnis
|
||||
// =========================================================================
|
||||
const withEvidence = obligations.filter(o => o.evidence && o.evidence.length > 0)
|
||||
const withoutEvidence = obligations.filter(o => !o.evidence || o.evidence.length === 0)
|
||||
|
||||
html += `
|
||||
<div class="section page-break">
|
||||
<div class="section-header">9. Nachweisverzeichnis</div>
|
||||
<div class="section-body">
|
||||
<p>${withEvidence.length} von ${obligations.length} Pflichten haben Nachweise hinterlegt.</p>
|
||||
`
|
||||
if (withEvidence.length > 0) {
|
||||
html += ` <table>
|
||||
<tr><th>Pflicht</th><th>Regulierung</th><th>Nachweise</th><th>Status</th></tr>
|
||||
`
|
||||
for (const o of withEvidence) {
|
||||
html += ` <tr>
|
||||
<td>${escHtml(o.title)}</td>
|
||||
<td>${escHtml(o.source)}</td>
|
||||
<td>${o.evidence!.map(e => escHtml(e)).join(', ')}</td>
|
||||
<td><span class="badge ${STATUS_BADGE_CLASSES[o.status] || 'badge-draft'}">${escHtml(STATUS_LABELS_DE[o.status] || o.status)}</span></td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
html += ` </table>
|
||||
`
|
||||
}
|
||||
|
||||
if (withoutEvidence.length > 0) {
|
||||
html += ` <p style="margin-top: 12px;"><strong>Pflichten ohne Nachweise (${withoutEvidence.length}):</strong></p>
|
||||
<ul style="margin: 4px 0 8px 24px; font-size: 9pt; color: #d97706;">
|
||||
`
|
||||
for (const o of withoutEvidence.slice(0, 15)) {
|
||||
html += ` <li>${escHtml(o.title)} (${escHtml(o.source)})</li>
|
||||
`
|
||||
}
|
||||
if (withoutEvidence.length > 15) {
|
||||
html += ` <li>... und ${withoutEvidence.length - 15} weitere</li>
|
||||
`
|
||||
}
|
||||
html += ` </ul>
|
||||
`
|
||||
}
|
||||
|
||||
html += ` </div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 10: Compliance-Status
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">10. Compliance-Status</div>
|
||||
<div class="section-body">
|
||||
`
|
||||
if (complianceResult) {
|
||||
const scoreClass = complianceResult.score >= 90 ? 'score-excellent'
|
||||
: complianceResult.score >= 75 ? 'score-good'
|
||||
: complianceResult.score >= 50 ? 'score-needs-work'
|
||||
: 'score-poor'
|
||||
const scoreLabel = complianceResult.score >= 90 ? 'Ausgezeichnet'
|
||||
: complianceResult.score >= 75 ? 'Gut'
|
||||
: complianceResult.score >= 50 ? 'Verbesserungswuerdig'
|
||||
: 'Mangelhaft'
|
||||
|
||||
html += ` <p><span class="score-box ${scoreClass}">${complianceResult.score}/100</span> ${escHtml(scoreLabel)}</p>
|
||||
<table style="margin-top: 12px;">
|
||||
<tr><th>Kennzahl</th><th>Wert</th></tr>
|
||||
<tr><td>Geprueft am</td><td>${formatDateDE(complianceResult.checkedAt)}</td></tr>
|
||||
<tr><td>Befunde gesamt</td><td>${complianceResult.summary.total}</td></tr>
|
||||
<tr><td>Kritisch</td><td>${complianceResult.summary.critical}</td></tr>
|
||||
<tr><td>Hoch</td><td>${complianceResult.summary.high}</td></tr>
|
||||
<tr><td>Mittel</td><td>${complianceResult.summary.medium}</td></tr>
|
||||
<tr><td>Niedrig</td><td>${complianceResult.summary.low}</td></tr>
|
||||
</table>
|
||||
`
|
||||
if (complianceResult.issues.length > 0) {
|
||||
html += ` <p style="margin-top: 12px;"><strong>Befunde nach Schweregrad:</strong></p>
|
||||
<table>
|
||||
<tr><th>Schweregrad</th><th>Befund</th><th>Betroffene Pflichten</th><th>Empfehlung</th></tr>
|
||||
`
|
||||
const severityOrder: ObligationComplianceIssueSeverity[] = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']
|
||||
for (const sev of severityOrder) {
|
||||
const issuesForSev = complianceResult.issues.filter(i => i.severity === sev)
|
||||
for (const issue of issuesForSev) {
|
||||
html += ` <tr>
|
||||
<td><span class="badge badge-${sev.toLowerCase()}" style="color: ${OBLIGATION_SEVERITY_COLORS[sev]}">${OBLIGATION_SEVERITY_LABELS_DE[sev]}</span></td>
|
||||
<td>${escHtml(issue.message)}</td>
|
||||
<td>${issue.affectedObligations.length > 0 ? issue.affectedObligations.length + ' Pflicht(en)' : '—'}</td>
|
||||
<td>${escHtml(issue.recommendation)}</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
}
|
||||
html += ` </table>
|
||||
`
|
||||
} else {
|
||||
html += ` <p style="margin-top: 8px;"><em>Keine Beanstandungen. Alle Pflichten sind konform.</em></p>
|
||||
`
|
||||
}
|
||||
} else {
|
||||
html += ` <p><em>Compliance-Check wurde noch nicht ausgefuehrt. Fuehren Sie den Check im
|
||||
Pflichtenregister-Tab durch, um den Status in das Dokument aufzunehmen.</em></p>
|
||||
`
|
||||
}
|
||||
|
||||
html += ` </div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 11: Aenderungshistorie
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">11. Aenderungshistorie</div>
|
||||
<div class="section-body">
|
||||
<table>
|
||||
<tr><th>Version</th><th>Datum</th><th>Autor</th><th>Aenderungen</th></tr>
|
||||
`
|
||||
if (revisions.length > 0) {
|
||||
for (const rev of revisions) {
|
||||
html += ` <tr>
|
||||
<td>${escHtml(rev.version)}</td>
|
||||
<td>${formatDateDE(rev.date)}</td>
|
||||
<td>${escHtml(rev.author)}</td>
|
||||
<td>${escHtml(rev.changes)}</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
} else {
|
||||
html += ` <tr>
|
||||
<td>${escHtml(orgHeader.documentVersion)}</td>
|
||||
<td>${today}</td>
|
||||
<td>${escHtml(orgHeader.dpoName || orgHeader.responsiblePerson || '—')}</td>
|
||||
<td>Erstversion des Pflichtenregisters</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
|
||||
html += ` </table>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Footer
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="page-footer">
|
||||
<span>Pflichtenregister — ${escHtml(orgName)}</span>
|
||||
<span>Stand: ${today} | Version ${escHtml(orgHeader.documentVersion)}</span>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// INTERNAL HELPERS
|
||||
// =============================================================================
|
||||
|
||||
function escHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function formatDateDE(dateStr: string | null | undefined): string {
|
||||
if (!dateStr) return '—'
|
||||
try {
|
||||
const date = new Date(dateStr)
|
||||
if (isNaN(date.getTime())) return '—'
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
})
|
||||
} catch {
|
||||
return '—'
|
||||
}
|
||||
}
|
||||
|
||||
function daysBetween(earlier: Date, later: Date): number {
|
||||
const diffMs = later.getTime() - earlier.getTime()
|
||||
return Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||
}
|
||||
@@ -796,16 +796,16 @@ export const SDK_STEPS: SDKStep[] = [
|
||||
},
|
||||
{
|
||||
id: 'vendor-compliance',
|
||||
seq: 4200,
|
||||
seq: 2500,
|
||||
phase: 2,
|
||||
package: 'betrieb',
|
||||
order: 3,
|
||||
package: 'dokumentation',
|
||||
order: 6,
|
||||
name: 'Vendor Compliance',
|
||||
nameShort: 'Vendor',
|
||||
description: 'Dienstleister-Management',
|
||||
url: '/sdk/vendor-compliance',
|
||||
checkpointId: 'CP-VEND',
|
||||
prerequisiteSteps: ['escalations'],
|
||||
prerequisiteSteps: ['vvt'],
|
||||
isOptional: false,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -120,25 +120,6 @@ export interface VVTActivity {
|
||||
art30Completeness?: VVTCompleteness
|
||||
}
|
||||
|
||||
// Processor-Record (Art. 30 Abs. 2)
|
||||
export interface VVTProcessorActivity {
|
||||
id: string
|
||||
vvtId: string
|
||||
controllerReference: string
|
||||
processingCategories: string[]
|
||||
subProcessorChain: SubProcessor[]
|
||||
thirdCountryTransfers: { country: string; recipient: string; transferMechanism: string }[]
|
||||
tomDescription: string
|
||||
status: 'DRAFT' | 'REVIEW' | 'APPROVED' | 'ARCHIVED'
|
||||
}
|
||||
|
||||
export interface SubProcessor {
|
||||
name: string
|
||||
purpose: string
|
||||
country: string
|
||||
isThirdCountry: boolean
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CONSTANTS
|
||||
// =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user