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:
Benjamin Admin
2026-03-19 13:59:43 +01:00
parent 4b1eede45b
commit c3afa628ed
19 changed files with 2852 additions and 421 deletions

View File

@@ -136,6 +136,9 @@ export default function LoeschfristenPage() {
// ---- VVT data ----
const [vvtActivities, setVvtActivities] = useState<any[]>([])
// ---- Vendor data ----
const [vendorList, setVendorList] = useState<Array<{id: string, name: string}>>([])
// ---- Loeschkonzept document state ----
const [orgHeader, setOrgHeader] = useState<LoeschkonzeptOrgHeader>(createDefaultLoeschkonzeptOrgHeader())
const [revisions, setRevisions] = useState<LoeschkonzeptRevision[]>([])
@@ -194,6 +197,7 @@ export default function LoeschfristenPage() {
responsiblePerson: raw.responsible_person || '',
releaseProcess: raw.release_process || '',
linkedVVTActivityIds: raw.linked_vvt_activity_ids || [],
linkedVendorIds: raw.linked_vendor_ids || [],
status: raw.status || 'DRAFT',
lastReviewDate: raw.last_review_date || base.lastReviewDate,
nextReviewDate: raw.next_review_date || base.nextReviewDate,
@@ -228,6 +232,7 @@ export default function LoeschfristenPage() {
responsible_person: p.responsiblePerson,
release_process: p.releaseProcess,
linked_vvt_activity_ids: p.linkedVVTActivityIds,
linked_vendor_ids: p.linkedVendorIds,
status: p.status,
last_review_date: p.lastReviewDate || null,
next_review_date: p.nextReviewDate || null,
@@ -257,6 +262,17 @@ export default function LoeschfristenPage() {
})
}, [tab, editingId])
// Load vendor list from API
useEffect(() => {
fetch('/api/sdk/v1/vendor-compliance/vendors?limit=500')
.then(r => r.ok ? r.json() : null)
.then(data => {
const items = data?.data?.items || []
setVendorList(items.map((v: any) => ({ id: v.id, name: v.name })))
})
.catch(() => {})
}, [])
// Load Loeschkonzept org header from VVT organization data + revisions from localStorage
useEffect(() => {
// Load revisions from localStorage
@@ -1408,13 +1424,13 @@ export default function LoeschfristenPage() {
Verarbeitungstaetigkeit aus Ihrem VVT.
</p>
<div className="space-y-2">
{policy.linkedVvtIds && policy.linkedVvtIds.length > 0 && (
{policy.linkedVVTActivityIds && policy.linkedVVTActivityIds.length > 0 && (
<div className="mb-3">
<label className="block text-xs font-medium text-gray-500 mb-1">
Verknuepfte Taetigkeiten:
</label>
<div className="flex flex-wrap gap-1">
{policy.linkedVvtIds.map((vvtId: string) => {
{policy.linkedVVTActivityIds.map((vvtId: string) => {
const activity = vvtActivities.find(
(a: any) => a.id === vvtId,
)
@@ -1429,8 +1445,8 @@ export default function LoeschfristenPage() {
onClick={() =>
updatePolicy(pid, (p) => ({
...p,
linkedVvtIds: (
p.linkedVvtIds || []
linkedVVTActivityIds: (
p.linkedVVTActivityIds || []
).filter((id: string) => id !== vvtId),
}))
}
@@ -1449,11 +1465,11 @@ export default function LoeschfristenPage() {
const val = e.target.value
if (
val &&
!(policy.linkedVvtIds || []).includes(val)
!(policy.linkedVVTActivityIds || []).includes(val)
) {
updatePolicy(pid, (p) => ({
...p,
linkedVvtIds: [...(p.linkedVvtIds || []), val],
linkedVVTActivityIds: [...(p.linkedVVTActivityIds || []), val],
}))
}
e.target.value = ''
@@ -1466,7 +1482,7 @@ export default function LoeschfristenPage() {
{vvtActivities
.filter(
(a: any) =>
!(policy.linkedVvtIds || []).includes(a.id),
!(policy.linkedVVTActivityIds || []).includes(a.id),
)
.map((a: any) => (
<option key={a.id} value={a.id}>
@@ -1485,6 +1501,95 @@ export default function LoeschfristenPage() {
)}
</div>
{/* Sektion 5b: Auftragsverarbeiter-Verknuepfung */}
<div className="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
<h3 className="text-lg font-semibold text-gray-900">
5b. Verknuepfte Auftragsverarbeiter
</h3>
{vendorList.length > 0 ? (
<div>
<p className="text-sm text-gray-500 mb-3">
Verknuepfen Sie diese Loeschfrist mit relevanten Auftragsverarbeitern.
</p>
<div className="space-y-2">
{policy.linkedVendorIds && policy.linkedVendorIds.length > 0 && (
<div className="mb-3">
<label className="block text-xs font-medium text-gray-500 mb-1">
Verknuepfte Auftragsverarbeiter:
</label>
<div className="flex flex-wrap gap-1">
{policy.linkedVendorIds.map((vendorId: string) => {
const vendor = vendorList.find(
(v) => v.id === vendorId,
)
return (
<span
key={vendorId}
className="inline-flex items-center gap-1 bg-orange-100 text-orange-800 text-xs font-medium px-2 py-0.5 rounded-full"
>
{vendor?.name || vendorId}
<button
type="button"
onClick={() =>
updatePolicy(pid, (p) => ({
...p,
linkedVendorIds: (
p.linkedVendorIds || []
).filter((id: string) => id !== vendorId),
}))
}
className="text-orange-600 hover:text-orange-900"
>
x
</button>
</span>
)
})}
</div>
</div>
)}
<select
onChange={(e) => {
const val = e.target.value
if (
val &&
!(policy.linkedVendorIds || []).includes(val)
) {
updatePolicy(pid, (p) => ({
...p,
linkedVendorIds: [...(p.linkedVendorIds || []), val],
}))
}
e.target.value = ''
}}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="">
Auftragsverarbeiter verknuepfen...
</option>
{vendorList
.filter(
(v) =>
!(policy.linkedVendorIds || []).includes(v.id),
)
.map((v) => (
<option key={v.id} value={v.id}>
{v.name || v.id}
</option>
))}
</select>
</div>
</div>
) : (
<p className="text-sm text-gray-400">
Keine Auftragsverarbeiter gefunden. Erstellen Sie zuerst
Auftragsverarbeiter im Vendor-Compliance-Modul, um hier Verknuepfungen
herstellen zu koennen.
</p>
)}
</div>
{/* Sektion 6: Review-Einstellungen */}
<div className="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
<h3 className="text-lg font-semibold text-gray-900">
@@ -2608,19 +2713,20 @@ export default function LoeschfristenPage() {
{/* Section list */}
<div className="border-t border-gray-200 pt-4">
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">11 Sektionen</div>
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">12 Sektionen</div>
<div className="grid grid-cols-2 gap-1 text-xs text-gray-600">
<div>1. Ziel und Zweck</div>
<div>7. Legal Hold Verfahren</div>
<div>7. Auftragsverarbeiter</div>
<div>2. Geltungsbereich</div>
<div>8. Verantwortlichkeiten</div>
<div>8. Legal Hold Verfahren</div>
<div>3. Grundprinzipien</div>
<div>9. Pruef-/Revisionszyklus</div>
<div>9. Verantwortlichkeiten</div>
<div>4. Loeschregeln-Uebersicht</div>
<div>10. Compliance-Status</div>
<div>10. Pruef-/Revisionszyklus</div>
<div>5. Detaillierte Loeschregeln</div>
<div>11. Aenderungshistorie</div>
<div>11. Compliance-Status</div>
<div>6. VVT-Verknuepfung</div>
<div>12. Aenderungshistorie</div>
</div>
</div>
@@ -2628,6 +2734,7 @@ export default function LoeschfristenPage() {
<div className="border-t border-gray-200 pt-4 mt-4 flex gap-6 text-xs text-gray-500">
<span><strong className="text-gray-700">{activePolicies.length}</strong> Loeschregeln</span>
<span><strong className="text-gray-700">{policies.filter(p => p.linkedVVTActivityIds.length > 0).length}</strong> VVT-Verknuepfungen</span>
<span><strong className="text-gray-700">{policies.filter(p => p.linkedVendorIds.length > 0).length}</strong> Vendor-Verknuepfungen</span>
<span><strong className="text-gray-700">{revisions.length}</strong> Revisionen</span>
{complianceResult && (
<span>Compliance-Score: <strong className={complianceResult.score >= 75 ? 'text-green-600' : complianceResult.score >= 50 ? 'text-yellow-600' : 'text-red-600'}>{complianceResult.score}/100</strong></span>

View File

@@ -1,35 +1,20 @@
'use client'
import React, { useState, useEffect, useCallback } from 'react'
import React, { useState, useEffect, useCallback, useMemo } from 'react'
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
import TOMControlPanel from '@/components/sdk/obligations/TOMControlPanel'
import GapAnalysisView from '@/components/sdk/obligations/GapAnalysisView'
import { ObligationDocumentTab } from '@/components/sdk/obligations/ObligationDocumentTab'
import { useSDK } from '@/lib/sdk'
import { buildAssessmentPayload } from '@/lib/sdk/scope-to-facts'
import type { ApplicableRegulation } from '@/lib/sdk/compliance-scope-types'
import type { Obligation, ObligationComplianceCheckResult } from '@/lib/sdk/obligations-compliance'
import { runObligationComplianceCheck } from '@/lib/sdk/obligations-compliance'
// =============================================================================
// Types
// Types (local only — Obligation imported from obligations-compliance.ts)
// =============================================================================
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[]
assessment_id?: string
rule_code?: string
notes?: string
created_at?: string
updated_at?: string
}
interface ObligationStats {
pending: number
in_progress: number
@@ -50,6 +35,7 @@ interface ObligationFormData {
priority: string
responsible: string
linked_systems: string
linked_vendor_ids: string
notes: string
}
@@ -63,11 +49,26 @@ const EMPTY_FORM: ObligationFormData = {
priority: 'medium',
responsible: '',
linked_systems: '',
linked_vendor_ids: '',
notes: '',
}
const API = '/api/sdk/v1/compliance/obligations'
// =============================================================================
// Tab definitions
// =============================================================================
type Tab = 'uebersicht' | 'editor' | 'profiling' | 'gap-analyse' | 'pflichtenregister'
const TABS: { key: Tab; label: string }[] = [
{ key: 'uebersicht', label: 'Uebersicht' },
{ key: 'editor', label: 'Detail-Editor' },
{ key: 'profiling', label: 'Profiling' },
{ key: 'gap-analyse', label: 'Gap-Analyse' },
{ key: 'pflichtenregister', label: 'Pflichtenregister' },
]
// =============================================================================
// Status helpers
// =============================================================================
@@ -262,6 +263,18 @@ function ObligationModal({
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Verknuepfte Auftragsverarbeiter</label>
<input
type="text"
value={form.linked_vendor_ids}
onChange={e => update('linked_vendor_ids', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500"
placeholder="Kommagetrennt: Vendor-ID-1, Vendor-ID-2"
/>
<p className="text-xs text-gray-400 mt-1">IDs der Auftragsverarbeiter aus dem Vendor Register</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Notizen</label>
<textarea
@@ -365,6 +378,19 @@ function ObligationDetail({ obligation, onClose, onStatusChange, onEdit, onDelet
</div>
)}
{obligation.linked_vendor_ids && obligation.linked_vendor_ids.length > 0 && (
<div>
<span className="text-gray-500">Verknuepfte Auftragsverarbeiter</span>
<div className="flex flex-wrap gap-1 mt-1">
{obligation.linked_vendor_ids.map(id => (
<a key={id} href="/sdk/vendor-compliance" className="px-2 py-0.5 text-xs bg-indigo-50 text-indigo-700 rounded hover:bg-indigo-100 transition-colors">
{id}
</a>
))}
</div>
</div>
)}
{obligation.notes && (
<div>
<span className="text-gray-500">Notizen</span>
@@ -559,9 +585,26 @@ export default function ObligationsPage() {
const [showModal, setShowModal] = useState(false)
const [editObligation, setEditObligation] = useState<Obligation | null>(null)
const [detailObligation, setDetailObligation] = useState<Obligation | null>(null)
const [showGapAnalysis, setShowGapAnalysis] = useState(false)
const [profiling, setProfiling] = useState(false)
const [applicableRegs, setApplicableRegs] = useState<ApplicableRegulation[]>([])
const [activeTab, setActiveTab] = useState<Tab>('uebersicht')
const [vendors, setVendors] = useState<Array<{id: string, name: string, role: string}>>([])
useEffect(() => {
fetch('/api/sdk/v1/vendor-compliance/vendors?limit=500')
.then(r => r.ok ? r.json() : null)
.then(data => {
const items = data?.data?.items || []
setVendors(items.map((v: any) => ({ id: v.id, name: v.name, role: v.role })))
})
.catch(() => {})
}, [])
// Compliance check result — auto-computed when obligations change
const complianceResult = useMemo<ObligationComplianceCheckResult | null>(() => {
if (obligations.length === 0) return null
return runObligationComplianceCheck(obligations)
}, [obligations])
const loadData = useCallback(async () => {
setLoading(true)
@@ -613,6 +656,7 @@ export default function ObligationsPage() {
priority: form.priority,
responsible: form.responsible || null,
linked_systems: form.linked_systems ? form.linked_systems.split(',').map(s => s.trim()).filter(Boolean) : [],
linked_vendor_ids: form.linked_vendor_ids ? form.linked_vendor_ids.split(',').map(s => s.trim()).filter(Boolean) : [],
notes: form.notes || null,
}),
})
@@ -634,12 +678,12 @@ export default function ObligationsPage() {
priority: form.priority,
responsible: form.responsible || null,
linked_systems: form.linked_systems ? form.linked_systems.split(',').map(s => s.trim()).filter(Boolean) : [],
linked_vendor_ids: form.linked_vendor_ids ? form.linked_vendor_ids.split(',').map(s => s.trim()).filter(Boolean) : [],
notes: form.notes || null,
}),
})
if (!res.ok) throw new Error('Aktualisierung fehlgeschlagen')
await loadData()
// Refresh detail if open
if (detailObligation?.id === id) {
const updated = await fetch(`${API}/${id}`)
if (updated.ok) setDetailObligation(await updated.json())
@@ -656,7 +700,6 @@ export default function ObligationsPage() {
const updated = await res.json()
setObligations(prev => prev.map(o => o.id === id ? updated : o))
if (detailObligation?.id === id) setDetailObligation(updated)
// Refresh stats
fetch(`${API}/stats`).then(r => r.json()).then(setStats).catch(() => {})
}
@@ -672,7 +715,6 @@ export default function ObligationsPage() {
setProfiling(true)
setError(null)
try {
// Build payload from real CompanyProfile + Scope data
const profile = sdkState.companyProfile
const scopeState = sdkState.complianceScope
const scopeAnswers = scopeState?.answers || []
@@ -682,7 +724,6 @@ export default function ObligationsPage() {
if (profile) {
payload = buildAssessmentPayload(profile, scopeAnswers, scopeDecision) as unknown as Record<string, unknown>
} else {
// Fallback: Minimaldaten wenn kein Profil vorhanden
payload = {
employee_count: 50,
industry: 'technology',
@@ -702,11 +743,9 @@ export default function ObligationsPage() {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
// Store applicable regulations for the info box
const regs: ApplicableRegulation[] = data.overview?.applicable_regulations || data.applicable_regulations || []
setApplicableRegs(regs)
// Extract obligations from response (can be nested under overview)
const rawObls = data.overview?.obligations || data.obligations || []
if (rawObls.length > 0) {
const autoObls: Obligation[] = rawObls.map((o: Record<string, unknown>) => ({
@@ -738,11 +777,9 @@ export default function ObligationsPage() {
const stepInfo = STEP_EXPLANATIONS['obligations']
const filteredObligations = obligations.filter(o => {
// Status/priority filter
if (filter === 'ai') {
if (!o.source.toLowerCase().includes('ai')) return false
}
// Regulation filter
if (regulationFilter !== 'all') {
const src = o.source?.toLowerCase() || ''
const key = regulationFilter.toLowerCase()
@@ -751,91 +788,12 @@ export default function ObligationsPage() {
return true
})
return (
<div className="space-y-6">
{/* Modals */}
{(showModal || editObligation) && !detailObligation && (
<ObligationModal
initial={editObligation ? {
title: editObligation.title,
description: editObligation.description,
source: editObligation.source,
source_article: editObligation.source_article,
deadline: editObligation.deadline ? editObligation.deadline.slice(0, 10) : '',
status: editObligation.status,
priority: editObligation.priority,
responsible: editObligation.responsible,
linked_systems: editObligation.linked_systems?.join(', ') || '',
notes: editObligation.notes || '',
} : undefined}
onClose={() => { setShowModal(false); setEditObligation(null) }}
onSave={async (form) => {
if (editObligation) {
await handleUpdate(editObligation.id, form)
setEditObligation(null)
} else {
await handleCreate(form)
setShowModal(false)
}
}}
/>
)}
{detailObligation && (
<ObligationDetail
obligation={detailObligation}
onClose={() => setDetailObligation(null)}
onStatusChange={handleStatusChange}
onDelete={handleDelete}
onEdit={() => {
setEditObligation(detailObligation)
setDetailObligation(null)
}}
/>
)}
{/* Header */}
<StepHeader
stepId="obligations"
title={stepInfo?.title || 'Pflichten-Management'}
description={stepInfo?.description || 'DSGVO & AI-Act Compliance-Pflichten verwalten'}
explanation={stepInfo?.explanation || ''}
tips={stepInfo?.tips || []}
>
<div className="flex items-center gap-2">
<button
onClick={handleAutoProfiling}
disabled={profiling}
className="flex items-center gap-2 px-4 py-2 bg-white border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50 transition-colors text-sm disabled:opacity-50"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
{profiling ? 'Profiling...' : 'Auto-Profiling'}
</button>
<button
onClick={() => setShowGapAnalysis(v => !v)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors text-sm ${
showGapAnalysis ? 'bg-purple-100 text-purple-700' : 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50'
}`}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
Gap-Analyse
</button>
<button
onClick={() => setShowModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Pflicht hinzufuegen
</button>
</div>
</StepHeader>
// ---------------------------------------------------------------------------
// Tab Content Renderers
// ---------------------------------------------------------------------------
const renderUebersichtTab = () => (
<>
{/* Error */}
{error && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 text-sm text-amber-700">{error}</div>
@@ -872,12 +830,13 @@ export default function ObligationsPage() {
)}
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
{[
{ label: 'Ausstehend', value: stats?.pending ?? 0, color: 'text-gray-600', border: 'border-gray-200' },
{ label: 'In Bearbeitung',value: stats?.in_progress ?? 0, color: 'text-blue-600', border: 'border-blue-200' },
{ label: 'Ueberfaellig', value: stats?.overdue ?? 0, color: 'text-red-600', border: 'border-red-200' },
{ label: 'Abgeschlossen', value: stats?.completed ?? 0, color: 'text-green-600', border: 'border-green-200'},
{ label: 'Compliance-Score', value: complianceResult ? complianceResult.score : '—', color: 'text-purple-600', border: 'border-purple-200'},
].map(s => (
<div key={s.label} className={`bg-white rounded-xl border ${s.border} p-5`}>
<div className={`text-xs ${s.color}`}>{s.label}</div>
@@ -901,9 +860,26 @@ export default function ObligationsPage() {
</div>
)}
{/* Gap Analysis View */}
{showGapAnalysis && (
<GapAnalysisView />
{/* Compliance Issues Summary */}
{complianceResult && complianceResult.issues.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-5">
<h3 className="text-sm font-semibold text-gray-900 mb-3">Compliance-Befunde ({complianceResult.issues.length})</h3>
<div className="space-y-2">
{complianceResult.issues.map((issue, i) => (
<div key={i} className="flex items-start gap-3 text-sm">
<span className={`px-2 py-0.5 text-xs rounded-full flex-shrink-0 ${
issue.severity === 'CRITICAL' ? 'bg-red-100 text-red-700' :
issue.severity === 'HIGH' ? 'bg-orange-100 text-orange-700' :
issue.severity === 'MEDIUM' ? 'bg-yellow-100 text-yellow-700' :
'bg-gray-100 text-gray-600'
}`}>
{issue.severity === 'CRITICAL' ? 'Kritisch' : issue.severity === 'HIGH' ? 'Hoch' : issue.severity === 'MEDIUM' ? 'Mittel' : 'Niedrig'}
</span>
<span className="text-gray-700">{issue.message}</span>
</div>
))}
</div>
</div>
)}
{/* Regulation Filter Chips */}
@@ -970,7 +946,7 @@ export default function ObligationsPage() {
</div>
<h3 className="text-base font-semibold text-gray-900">Keine Pflichten gefunden</h3>
<p className="mt-2 text-sm text-gray-500">
Klicken Sie auf "Pflicht hinzufuegen", um die erste Compliance-Pflicht zu erfassen.
Klicken Sie auf &quot;Pflicht hinzufuegen&quot;, um die erste Compliance-Pflicht zu erfassen.
</p>
<button
onClick={() => setShowModal(true)}
@@ -982,6 +958,220 @@ export default function ObligationsPage() {
)}
</div>
)}
</>
)
const renderEditorTab = () => (
<>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold text-gray-900">Pflichten bearbeiten ({obligations.length})</h3>
<button
onClick={() => setShowModal(true)}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-sm"
>
Pflicht hinzufuegen
</button>
</div>
{loading && <p className="text-gray-500 text-sm">Lade...</p>}
{!loading && obligations.length === 0 && (
<p className="text-gray-500 text-sm">Noch keine Pflichten vorhanden. Erstellen Sie eine neue Pflicht oder nutzen Sie Auto-Profiling.</p>
)}
{!loading && obligations.length > 0 && (
<div className="space-y-2 max-h-[60vh] overflow-y-auto">
{obligations.map(o => (
<div
key={o.id}
className="flex items-center justify-between p-3 border border-gray-100 rounded-lg hover:bg-gray-50 cursor-pointer"
onClick={() => {
setEditObligation(o)
}}
>
<div className="flex items-center gap-3 min-w-0">
<span className={`px-2 py-0.5 text-xs rounded-full flex-shrink-0 ${STATUS_COLORS[o.status]}`}>
{STATUS_LABELS[o.status]}
</span>
<span className={`px-2 py-0.5 text-xs rounded-full flex-shrink-0 ${PRIORITY_COLORS[o.priority]}`}>
{PRIORITY_LABELS[o.priority]}
</span>
<span className="text-sm text-gray-900 truncate">{o.title}</span>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<span className="text-xs text-gray-400">{o.source}</span>
<button
onClick={(e) => { e.stopPropagation(); setEditObligation(o) }}
className="text-xs text-purple-600 hover:text-purple-700 font-medium"
>
Bearbeiten
</button>
</div>
</div>
))}
</div>
)}
</div>
</>
)
const renderProfilingTab = () => (
<>
{error && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 text-sm text-amber-700">{error}</div>
)}
{!sdkState.companyProfile && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 text-sm text-yellow-700">
Kein Unternehmensprofil vorhanden. Auto-Profiling verwendet Beispieldaten.{' '}
<a href="/sdk/company-profile" className="underline font-medium">Profil anlegen </a>
</div>
)}
<div className="bg-white rounded-xl border border-gray-200 p-6 text-center">
<div className="w-12 h-12 mx-auto bg-purple-50 rounded-full flex items-center justify-center mb-3">
<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<h3 className="text-sm font-semibold text-gray-900">Auto-Profiling</h3>
<p className="text-xs text-gray-500 mt-1 mb-4">
Ermittelt automatisch anwendbare Regulierungen und Pflichten aus dem Unternehmensprofil und Compliance-Scope.
</p>
<button
onClick={handleAutoProfiling}
disabled={profiling}
className="px-5 py-2 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
{profiling ? 'Profiling laeuft...' : 'Auto-Profiling starten'}
</button>
</div>
{applicableRegs.length > 0 && (
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
<h3 className="text-sm font-semibold text-blue-900 mb-2">Anwendbare Regulierungen</h3>
<div className="flex flex-wrap gap-2">
{applicableRegs.map(reg => (
<span
key={reg.id}
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-white border border-blue-300 text-blue-800"
>
<svg className="w-3.5 h-3.5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
{reg.name}
{reg.classification && <span className="text-blue-500">({reg.classification})</span>}
<span className="text-blue-400">{reg.obligation_count} Pflichten</span>
</span>
))}
</div>
</div>
)}
</>
)
const renderGapAnalyseTab = () => (
<GapAnalysisView />
)
const renderPflichtenregisterTab = () => (
<ObligationDocumentTab
obligations={obligations}
complianceResult={complianceResult}
/>
)
const renderTabContent = () => {
switch (activeTab) {
case 'uebersicht': return renderUebersichtTab()
case 'editor': return renderEditorTab()
case 'profiling': return renderProfilingTab()
case 'gap-analyse': return renderGapAnalyseTab()
case 'pflichtenregister': return renderPflichtenregisterTab()
}
}
return (
<div className="space-y-6">
{/* Modals */}
{(showModal || editObligation) && !detailObligation && (
<ObligationModal
initial={editObligation ? {
title: editObligation.title,
description: editObligation.description,
source: editObligation.source,
source_article: editObligation.source_article,
deadline: editObligation.deadline ? editObligation.deadline.slice(0, 10) : '',
status: editObligation.status,
priority: editObligation.priority,
responsible: editObligation.responsible,
linked_systems: editObligation.linked_systems?.join(', ') || '',
notes: editObligation.notes || '',
} : undefined}
onClose={() => { setShowModal(false); setEditObligation(null) }}
onSave={async (form) => {
if (editObligation) {
await handleUpdate(editObligation.id, form)
setEditObligation(null)
} else {
await handleCreate(form)
setShowModal(false)
}
}}
/>
)}
{detailObligation && (
<ObligationDetail
obligation={detailObligation}
onClose={() => setDetailObligation(null)}
onStatusChange={handleStatusChange}
onDelete={handleDelete}
onEdit={() => {
setEditObligation(detailObligation)
setDetailObligation(null)
}}
/>
)}
{/* Header */}
<StepHeader
stepId="obligations"
title={stepInfo?.title || 'Pflichten-Management'}
description={stepInfo?.description || 'DSGVO & AI-Act Compliance-Pflichten verwalten'}
explanation={stepInfo?.explanation || ''}
tips={stepInfo?.tips || []}
>
<div className="flex items-center gap-2">
<button
onClick={() => setShowModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Pflicht hinzufuegen
</button>
</div>
</StepHeader>
{/* Tab Navigation */}
<div className="flex gap-1 border-b border-gray-200">
{TABS.map(tab => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`px-4 py-2.5 text-sm font-medium transition-colors ${
activeTab === tab.key
? 'border-b-2 border-purple-500 text-purple-700'
: 'text-gray-500 hover:text-gray-700 hover:border-b-2 hover:border-gray-300'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Tab Content */}
{renderTabContent()}
</div>
)
}

View File

@@ -44,6 +44,16 @@ export default function TOMPage() {
const [tab, setTab] = useState<Tab>('uebersicht')
const [selectedTOMId, setSelectedTOMId] = useState<string | null>(null)
const [complianceResult, setComplianceResult] = useState<TOMComplianceCheckResult | null>(null)
const [vendorControls, setVendorControls] = useState<Array<{
vendorId: string
vendorName: string
controlId: string
controlName: string
domain: string
status: string
lastTestedAt?: string
}>>([])
const [vendorControlsLoading, setVendorControlsLoading] = useState(false)
// ---------------------------------------------------------------------------
// Compliance check (auto-run when derivedTOMs change)
@@ -55,6 +65,39 @@ export default function TOMPage() {
}
}, [state?.derivedTOMs])
// ---------------------------------------------------------------------------
// Vendor controls cross-reference (fetch when overview tab is active)
// ---------------------------------------------------------------------------
useEffect(() => {
if (tab !== 'uebersicht') return
setVendorControlsLoading(true)
Promise.all([
fetch('/api/sdk/v1/vendor-compliance/control-instances?limit=500').then(r => r.ok ? r.json() : null),
fetch('/api/sdk/v1/vendor-compliance/vendors?limit=500').then(r => r.ok ? r.json() : null),
]).then(([ciData, vendorData]) => {
const instances = ciData?.data?.items || []
const vendors = vendorData?.data?.items || []
const vendorMap = new Map<string, string>()
for (const v of vendors) {
vendorMap.set(v.id, v.name)
}
// Filter for TOM-domain controls
const tomControls = instances
.filter((ci: any) => ci.domain === 'TOM' || ci.controlId?.startsWith('VND-TOM'))
.map((ci: any) => ({
vendorId: ci.vendorId || ci.vendor_id,
vendorName: vendorMap.get(ci.vendorId || ci.vendor_id) || 'Unbekannt',
controlId: ci.controlId || ci.control_id,
controlName: ci.controlName || ci.control_name || ci.controlId || ci.control_id,
domain: ci.domain || 'TOM',
status: ci.status || 'UNKNOWN',
lastTestedAt: ci.lastTestedAt || ci.last_tested_at,
}))
setVendorControls(tomControls)
}).catch(() => {}).finally(() => setVendorControlsLoading(false))
}, [tab])
// ---------------------------------------------------------------------------
// Computed / memoised values
// ---------------------------------------------------------------------------
@@ -377,6 +420,60 @@ export default function TOMPage() {
{/* Active tab content */}
<div>{renderActiveTab()}</div>
{/* Vendor-Controls cross-reference (only on overview tab) */}
{tab === 'uebersicht' && vendorControls.length > 0 && (
<div className="mt-6 bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-base font-semibold text-gray-900">Auftragsverarbeiter-Controls (Art. 28)</h3>
<p className="text-sm text-gray-500 mt-0.5">TOM-relevante Controls aus dem Vendor Register</p>
</div>
<a href="/sdk/vendor-compliance" className="text-sm text-purple-600 hover:text-purple-700 font-medium">
Zum Vendor Register &rarr;
</a>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-2 px-3 text-xs font-medium text-gray-500 uppercase">Vendor</th>
<th className="text-left py-2 px-3 text-xs font-medium text-gray-500 uppercase">Control</th>
<th className="text-left py-2 px-3 text-xs font-medium text-gray-500 uppercase">Status</th>
<th className="text-left py-2 px-3 text-xs font-medium text-gray-500 uppercase">Letzte Pruefung</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{vendorControls.map((vc, i) => (
<tr key={`${vc.vendorId}-${vc.controlId}-${i}`} className="hover:bg-gray-50">
<td className="py-2.5 px-3 font-medium text-gray-900">{vc.vendorName}</td>
<td className="py-2.5 px-3">
<span className="font-mono text-xs text-gray-500">{vc.controlId}</span>
<span className="ml-2 text-gray-700">{vc.controlName !== vc.controlId ? vc.controlName : ''}</span>
</td>
<td className="py-2.5 px-3">
<span className={`px-2 py-0.5 text-xs rounded-full ${
vc.status === 'PASS' ? 'bg-green-100 text-green-700' :
vc.status === 'PARTIAL' ? 'bg-yellow-100 text-yellow-700' :
vc.status === 'FAIL' ? 'bg-red-100 text-red-700' :
'bg-gray-100 text-gray-600'
}`}>
{vc.status === 'PASS' ? 'Bestanden' :
vc.status === 'PARTIAL' ? 'Teilweise' :
vc.status === 'FAIL' ? 'Nicht bestanden' :
vc.status}
</span>
</td>
<td className="py-2.5 px-3 text-gray-500">
{vc.lastTestedAt ? new Date(vc.lastTestedAt).toLocaleDateString('de-DE') : '\u2014'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
)
}

View File

@@ -1821,63 +1821,124 @@ function TabDokument({ activities, orgHeader }: { activities: VVTActivity[]; org
// TAB 5: AUFTRAGSVERARBEITER (Art. 30 Abs. 2)
// =============================================================================
interface ProcessorRecord {
interface VendorForProcessor {
id: string
vvtId: string
controllerName: string
controllerContact: string
processingCategories: string[]
subProcessors: { name: string; purpose: string; country: string; isThirdCountry: boolean }[]
thirdCountryTransfers: { country: string; recipient: string; transferMechanism: string }[]
tomDescription: string
status: 'DRAFT' | 'REVIEW' | 'APPROVED' | 'ARCHIVED'
name: string
role: string
serviceDescription: string
country: string
processingLocations: { country: string; region?: string; isEU: boolean; isAdequate: boolean }[]
transferMechanisms: string[]
certifications: { type: string; expirationDate?: string }[]
status: string
primaryContact: { name: string; email: string; phone?: string }
dpoContact?: { name: string; email: string }
contractTypes: string[]
inherentRiskScore: number
residualRiskScore: number
nextReviewDate?: string
processingActivityIds: string[]
notes?: string
createdAt: string
updatedAt: string
}
function createEmptyProcessorRecord(): ProcessorRecord {
const now = new Date().toISOString()
return {
id: crypto.randomUUID(),
vvtId: 'AVV-001',
controllerName: '',
controllerContact: '',
processingCategories: [],
subProcessors: [],
thirdCountryTransfers: [],
tomDescription: '',
status: 'DRAFT',
createdAt: now,
updatedAt: now,
}
async function apiListProcessorVendors(): Promise<VendorForProcessor[]> {
const res = await fetch('/api/sdk/v1/vendor-compliance/vendors?limit=500')
if (!res.ok) throw new Error(`Vendor API error: ${res.status}`)
const data = await res.json()
const items: any[] = data?.data?.items ?? []
return items
.filter((v: any) => v.role === 'PROCESSOR' || v.role === 'SUB_PROCESSOR')
.map((v: any) => ({
id: v.id,
name: v.name ?? '',
role: v.role ?? '',
serviceDescription: v.serviceDescription ?? v.service_description ?? '',
country: v.country ?? '',
processingLocations: (v.processingLocations ?? v.processing_locations ?? []).map((l: any) => ({
country: l.country ?? '',
region: l.region,
isEU: l.isEU ?? l.is_eu ?? false,
isAdequate: l.isAdequate ?? l.is_adequate ?? false,
})),
transferMechanisms: v.transferMechanisms ?? v.transfer_mechanisms ?? [],
certifications: (v.certifications ?? []).map((c: any) => ({
type: c.type ?? '',
expirationDate: c.expirationDate ?? c.expiration_date,
})),
status: v.status ?? 'ACTIVE',
primaryContact: {
name: v.primaryContact?.name ?? v.primary_contact?.name ?? '',
email: v.primaryContact?.email ?? v.primary_contact?.email ?? '',
phone: v.primaryContact?.phone ?? v.primary_contact?.phone,
},
dpoContact: (v.dpoContact ?? v.dpo_contact) ? {
name: (v.dpoContact ?? v.dpo_contact).name ?? '',
email: (v.dpoContact ?? v.dpo_contact).email ?? '',
} : undefined,
contractTypes: v.contractTypes ?? v.contract_types ?? [],
inherentRiskScore: v.inherentRiskScore ?? v.inherent_risk_score ?? 0,
residualRiskScore: v.residualRiskScore ?? v.residual_risk_score ?? 0,
nextReviewDate: v.nextReviewDate ?? v.next_review_date,
processingActivityIds: v.processingActivityIds ?? v.processing_activity_ids ?? [],
notes: v.notes,
createdAt: v.createdAt ?? v.created_at ?? '',
updatedAt: v.updatedAt ?? v.updated_at ?? '',
}))
}
const VENDOR_STATUS_LABELS: Record<string, string> = {
ACTIVE: 'Aktiv',
PENDING_REVIEW: 'In Pruefung',
APPROVED: 'Genehmigt',
SUSPENDED: 'Ausgesetzt',
ARCHIVED: 'Archiviert',
DRAFT: 'Entwurf',
REVIEW: 'In Pruefung',
}
const VENDOR_STATUS_COLORS: Record<string, string> = {
ACTIVE: 'bg-green-100 text-green-700',
PENDING_REVIEW: 'bg-yellow-100 text-yellow-700',
APPROVED: 'bg-green-100 text-green-800',
SUSPENDED: 'bg-red-100 text-red-700',
ARCHIVED: 'bg-gray-100 text-gray-600',
DRAFT: 'bg-gray-100 text-gray-600',
REVIEW: 'bg-yellow-100 text-yellow-700',
}
const ROLE_LABELS: Record<string, string> = {
PROCESSOR: 'Auftragsverarbeiter',
SUB_PROCESSOR: 'Unterauftragsverarbeiter',
}
function riskColor(score: number): string {
if (score <= 3) return 'bg-green-100 text-green-700'
if (score <= 6) return 'bg-yellow-100 text-yellow-700'
return 'bg-red-100 text-red-700'
}
function TabProcessor({ orgHeader }: { orgHeader: VVTOrganizationHeader }) {
const [records, setRecords] = useState<ProcessorRecord[]>([])
const [editingRecord, setEditingRecord] = useState<ProcessorRecord | null>(null)
const [vendors, setVendors] = useState<VendorForProcessor[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const handleAdd = () => {
const nextNum = records.length + 1
const rec = createEmptyProcessorRecord()
rec.vvtId = `AVV-${String(nextNum).padStart(3, '0')}`
setRecords(prev => [...prev, rec])
setEditingRecord(rec)
}
const handleSave = (updated: ProcessorRecord) => {
updated.updatedAt = new Date().toISOString()
setRecords(prev => prev.map(r => r.id === updated.id ? updated : r))
setEditingRecord(null)
}
const handleDelete = (id: string) => {
setRecords(prev => prev.filter(r => r.id !== id))
if (editingRecord?.id === id) setEditingRecord(null)
}
useEffect(() => {
let cancelled = false
setLoading(true)
setError(null)
apiListProcessorVendors()
.then(data => { if (!cancelled) setVendors(data) })
.catch(err => { if (!cancelled) setError(err.message ?? 'Fehler beim Laden der Auftragsverarbeiter') })
.finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [])
const handlePrintProcessorDoc = () => {
const today = new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
const activeRecords = records.filter(r => r.status !== 'ARCHIVED')
const activeVendors = vendors.filter(v => v.status !== 'ARCHIVED')
const subProcessors = vendors.filter(v => v.role === 'SUB_PROCESSOR')
let html = `
<!DOCTYPE html>
@@ -1915,21 +1976,30 @@ function TabProcessor({ orgHeader }: { orgHeader: VVTOrganizationHeader }) {
</div>
`
for (const r of activeRecords) {
for (const v of activeVendors) {
const thirdCountryLocations = v.processingLocations.filter(l => !l.isEU && !l.isAdequate)
const thirdCountryHtml = thirdCountryLocations.length > 0
? thirdCountryLocations.map(l => `${l.country}${l.region ? ` (${l.region})` : ''}`).join(', ') +
(v.transferMechanisms.length > 0 ? `<br/>Garantien: ${v.transferMechanisms.join(', ')}` : '')
: 'Keine Drittlanduebermittlung'
const subProcessorHtml = subProcessors.length > 0
? subProcessors.map(s => `${s.name}${s.serviceDescription || s.country}`).join('<br/>')
: 'Keine'
html += `
<div class="record">
<div class="record-header">
<span class="vvt-id">${r.vvtId}</span>
<h3>Auftragsverarbeitung fuer: ${r.controllerName || '(Verantwortlicher)'}</h3>
<span class="vvt-id">${ROLE_LABELS[v.role] ?? v.role}</span>
<h3>${v.name}</h3>
</div>
<table>
<tr><th style="width:35%">Pflichtfeld (Art. 30 Abs. 2)</th><th>Inhalt</th></tr>
<tr><td><strong>Name/Kontaktdaten des Auftragsverarbeiters</strong></td><td>${orgHeader.organizationName}${orgHeader.dpoContact ? `<br/>Kontakt: ${orgHeader.dpoContact}` : ''}</td></tr>
<tr><td><strong>Name/Kontaktdaten des Verantwortlichen</strong></td><td>${r.controllerName || '<em style="color:#9ca3af;">nicht angegeben</em>'}${r.controllerContact ? `<br/>Kontakt: ${r.controllerContact}` : ''}</td></tr>
<tr><td><strong>Kategorien von Verarbeitungen</strong></td><td>${r.processingCategories.length > 0 ? r.processingCategories.join('; ') : '<em style="color:#9ca3af;">nicht angegeben</em>'}</td></tr>
<tr><td><strong>Unterauftragsverarbeiter</strong></td><td>${r.subProcessors.length > 0 ? r.subProcessors.map(s => `${s.name} (${s.purpose}) — ${s.country}${s.isThirdCountry ? ' (Drittland)' : ''}`).join('<br/>') : 'Keine'}</td></tr>
<tr><td><strong>Uebermittlung an Drittlaender</strong></td><td>${r.thirdCountryTransfers.length > 0 ? r.thirdCountryTransfers.map(t => `${t.country}: ${t.recipient}${t.transferMechanism}`).join('<br/>') : 'Keine Drittlanduebermittlung'}</td></tr>
<tr><td><strong>TOM (Art. 32 DSGVO)</strong></td><td>${r.tomDescription || '<em style="color:#9ca3af;">nicht beschrieben</em>'}</td></tr>
<tr><td><strong>Name/Kontaktdaten des Verantwortlichen</strong></td><td>${v.name}${v.primaryContact.email ? `<br/>Kontakt: ${v.primaryContact.email}` : ''}</td></tr>
<tr><td><strong>Kategorien von Verarbeitungen</strong></td><td>${v.serviceDescription || '<em style="color:#9ca3af;">nicht angegeben</em>'}</td></tr>
<tr><td><strong>Unterauftragsverarbeiter</strong></td><td>${subProcessorHtml}</td></tr>
<tr><td><strong>Uebermittlung an Drittlaender</strong></td><td>${thirdCountryHtml}</td></tr>
<tr><td><strong>TOM (Art. 32 DSGVO)</strong></td><td>Siehe TOM-Dokumentation im Vendor-Compliance-Modul</td></tr>
</table>
</div>
`
@@ -1951,229 +2021,189 @@ function TabProcessor({ orgHeader }: { orgHeader: VVTOrganizationHeader }) {
}
}
// Editor mode
if (editingRecord) {
const update = (patch: Partial<ProcessorRecord>) => setEditingRecord(prev => prev ? { ...prev, ...patch } : prev)
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button onClick={() => setEditingRecord(null)} className="p-2 hover:bg-gray-100 rounded-lg">
<svg className="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
</button>
<div>
<span className="text-sm font-mono text-gray-400">{editingRecord.vvtId}</span>
<h2 className="text-lg font-bold text-gray-900">Auftragsverarbeitung bearbeiten</h2>
</div>
</div>
<div className="flex items-center gap-2">
<select value={editingRecord.status}
onChange={(e) => update({ status: e.target.value as ProcessorRecord['status'] })}
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm">
<option value="DRAFT">Entwurf</option>
<option value="REVIEW">In Pruefung</option>
<option value="APPROVED">Genehmigt</option>
<option value="ARCHIVED">Archiviert</option>
</select>
<button onClick={() => handleSave(editingRecord)} className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-sm">
Speichern
</button>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 divide-y divide-gray-100">
<FormSection title="Verantwortlicher (Auftraggeber)">
<div className="grid grid-cols-2 gap-4">
<FormField label="Name des Verantwortlichen *">
<input type="text" value={editingRecord.controllerName}
onChange={(e) => update({ controllerName: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg" placeholder="Firma des Auftraggebers" />
</FormField>
<FormField label="Kontaktdaten">
<input type="text" value={editingRecord.controllerContact}
onChange={(e) => update({ controllerContact: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg" placeholder="E-Mail oder Adresse" />
</FormField>
</div>
</FormSection>
<FormSection title="Kategorien von Verarbeitungen *">
<MultiTextInput
values={editingRecord.processingCategories}
onChange={(processingCategories) => update({ processingCategories })}
placeholder="Verarbeitungskategorie eingeben und Enter druecken"
/>
</FormSection>
<FormSection title="Unterauftragsverarbeiter (Sub-Processors)">
<div className="space-y-2">
{editingRecord.subProcessors.map((sp, i) => (
<div key={i} className="flex items-center gap-2 flex-wrap">
<input type="text" value={sp.name}
onChange={(e) => { const copy = [...editingRecord.subProcessors]; copy[i] = { ...copy[i], name: e.target.value }; update({ subProcessors: copy }) }}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm" placeholder="Name" />
<input type="text" value={sp.purpose}
onChange={(e) => { const copy = [...editingRecord.subProcessors]; copy[i] = { ...copy[i], purpose: e.target.value }; update({ subProcessors: copy }) }}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm" placeholder="Zweck" />
<input type="text" value={sp.country}
onChange={(e) => { const copy = [...editingRecord.subProcessors]; copy[i] = { ...copy[i], country: e.target.value }; update({ subProcessors: copy }) }}
className="w-24 px-3 py-2 border border-gray-300 rounded-lg text-sm" placeholder="Land" />
<label className="flex items-center gap-1 text-xs text-gray-600">
<input type="checkbox" checked={sp.isThirdCountry}
onChange={(e) => { const copy = [...editingRecord.subProcessors]; copy[i] = { ...copy[i], isThirdCountry: e.target.checked }; update({ subProcessors: copy }) }}
className="w-3.5 h-3.5" />
Drittland
</label>
<button onClick={() => update({ subProcessors: editingRecord.subProcessors.filter((_, j) => j !== i) })}
className="p-2 text-gray-400 hover:text-red-500">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
<button onClick={() => update({ subProcessors: [...editingRecord.subProcessors, { name: '', purpose: '', country: '', isThirdCountry: false }] })}
className="text-sm text-purple-600 hover:text-purple-700">
+ Unterauftragsverarbeiter hinzufuegen
</button>
</div>
</FormSection>
<FormSection title="Drittlandtransfers">
<div className="space-y-2">
{editingRecord.thirdCountryTransfers.map((tc, i) => (
<div key={i} className="flex items-center gap-2">
<input type="text" value={tc.country}
onChange={(e) => { const copy = [...editingRecord.thirdCountryTransfers]; copy[i] = { ...copy[i], country: e.target.value }; update({ thirdCountryTransfers: copy }) }}
className="w-20 px-3 py-2 border border-gray-300 rounded-lg text-sm" placeholder="Land" />
<input type="text" value={tc.recipient}
onChange={(e) => { const copy = [...editingRecord.thirdCountryTransfers]; copy[i] = { ...copy[i], recipient: e.target.value }; update({ thirdCountryTransfers: copy }) }}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm" placeholder="Empfaenger" />
<select value={tc.transferMechanism}
onChange={(e) => { const copy = [...editingRecord.thirdCountryTransfers]; copy[i] = { ...copy[i], transferMechanism: e.target.value }; update({ thirdCountryTransfers: copy }) }}
className="w-56 px-3 py-2 border border-gray-300 rounded-lg text-sm">
<option value="">-- Mechanismus --</option>
{Object.entries(TRANSFER_MECHANISM_META).map(([k, v]) => (
<option key={k} value={k}>{v.de}</option>
))}
</select>
<button onClick={() => update({ thirdCountryTransfers: editingRecord.thirdCountryTransfers.filter((_, j) => j !== i) })}
className="p-2 text-gray-400 hover:text-red-500">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
<button onClick={() => update({ thirdCountryTransfers: [...editingRecord.thirdCountryTransfers, { country: '', recipient: '', transferMechanism: '' }] })}
className="text-sm text-purple-600 hover:text-purple-700">
+ Drittlandtransfer hinzufuegen
</button>
</div>
</FormSection>
<FormSection title="TOM-Beschreibung (Art. 32) *">
<textarea value={editingRecord.tomDescription}
onChange={(e) => update({ tomDescription: e.target.value })}
rows={4} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
placeholder="Beschreiben Sie die technischen und organisatorischen Massnahmen gemaess Art. 32 DSGVO" />
</FormSection>
</div>
<div className="flex items-center justify-end gap-3">
<button onClick={() => setEditingRecord(null)} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg">Zurueck</button>
<button onClick={() => handleSave(editingRecord)} className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700">Speichern</button>
</div>
</div>
)
}
// List mode
return (
<div className="space-y-4">
{/* Info banner */}
<div className="bg-purple-50 border border-purple-200 rounded-xl p-4 flex items-start gap-3">
<svg className="w-5 h-5 text-purple-500 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div className="flex-1">
<p className="text-sm text-purple-800">
Dieses Verzeichnis zeigt alle Auftragsverarbeiter aus dem Vendor Register.
Neue Auftragsverarbeiter hinzufuegen oder bestehende bearbeiten:
</p>
<a href="/sdk/vendor-compliance"
className="inline-flex items-center gap-1.5 mt-2 px-3 py-1.5 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700 transition-colors">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Zum Vendor Register
</a>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-gray-900">Auftragsverarbeiter-Verzeichnis (Art. 30 Abs. 2)</h3>
<p className="text-sm text-gray-500 mt-0.5">
Wenn Ihr Unternehmen als Auftragsverarbeiter fuer andere Verantwortliche taetig ist,
muessen Sie ein separates Verzeichnis fuehren.
Auftragsverarbeiter und Unterauftragsverarbeiter aus dem Vendor-Compliance-Modul (nur lesen).
</p>
</div>
<div className="flex items-center gap-2">
{records.length > 0 && (
<button
onClick={handlePrintProcessorDoc}
className="flex items-center gap-2 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 text-sm"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
</svg>
Als PDF drucken
</button>
)}
{vendors.length > 0 && (
<button
onClick={handleAdd}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-sm"
onClick={handlePrintProcessorDoc}
className="flex items-center gap-2 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 text-sm"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
</svg>
Neue Auftragsverarbeitung
Als PDF drucken
</button>
</div>
)}
</div>
{records.length === 0 ? (
{/* Loading state */}
{loading && (
<div className="p-8 text-center">
<div className="w-8 h-8 mx-auto border-2 border-purple-200 border-t-purple-600 rounded-full animate-spin mb-3" />
<p className="text-sm text-gray-500">Auftragsverarbeiter werden geladen...</p>
</div>
)}
{/* Error state */}
{!loading && error && (
<div className="p-6 bg-red-50 border border-red-200 rounded-lg text-center">
<p className="text-sm text-red-700 mb-2">{error}</p>
<button onClick={() => {
setLoading(true)
setError(null)
apiListProcessorVendors()
.then(setVendors)
.catch(err => setError(err.message))
.finally(() => setLoading(false))
}} className="text-sm text-red-600 underline hover:text-red-800">
Erneut versuchen
</button>
</div>
)}
{/* Empty state */}
{!loading && !error && vendors.length === 0 && (
<div className="p-8 bg-gray-50 rounded-lg text-center">
<div className="w-12 h-12 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-3">
<svg className="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<h4 className="font-medium text-gray-700 mb-1">Kein Auftragsverarbeiter-Verzeichnis</h4>
<h4 className="font-medium text-gray-700 mb-1">Keine Auftragsverarbeiter im Vendor Register</h4>
<p className="text-sm text-gray-500 max-w-md mx-auto">
Dieses Verzeichnis wird nur benoetigt, wenn Ihr Unternehmen personenbezogene Daten
im Auftrag eines anderen Verantwortlichen verarbeitet (Art. 30 Abs. 2 DSGVO).
Legen Sie Auftragsverarbeiter im Vendor Register an, damit sie hier automatisch erscheinen.
</p>
<a href="/sdk/vendor-compliance"
className="inline-flex items-center gap-1.5 mt-3 px-4 py-2 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700 transition-colors">
Zum Vendor Register
</a>
</div>
) : (
)}
{/* Vendor cards (read-only) */}
{!loading && !error && vendors.length > 0 && (
<div className="space-y-3">
{records.map(r => (
<div key={r.id} className="bg-white rounded-xl border border-gray-200 p-5 hover:border-purple-200 transition-colors">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className="text-xs font-mono text-gray-400">{r.vvtId}</span>
<span className={`px-2 py-0.5 text-xs rounded-full ${STATUS_COLORS[r.status]}`}>{STATUS_LABELS[r.status]}</span>
{vendors.map(v => {
const thirdCountryLocations = v.processingLocations.filter(l => !l.isEU && !l.isAdequate)
return (
<div key={v.id} className="bg-white rounded-xl border border-gray-200 p-5 hover:border-purple-200 transition-colors">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
{/* Header: Name + Role + Status */}
<div className="flex items-center gap-2 mb-1 flex-wrap">
<h4 className="text-base font-semibold text-gray-900">{v.name}</h4>
<span className="px-2 py-0.5 text-xs rounded-full bg-purple-100 text-purple-700">
{ROLE_LABELS[v.role] ?? v.role}
</span>
<span className={`px-2 py-0.5 text-xs rounded-full ${VENDOR_STATUS_COLORS[v.status] ?? 'bg-gray-100 text-gray-600'}`}>
{VENDOR_STATUS_LABELS[v.status] ?? v.status}
</span>
</div>
{/* Service description */}
{v.serviceDescription && (
<p className="text-sm text-gray-600 mt-1">{v.serviceDescription}</p>
)}
{/* Contact */}
<div className="flex items-center gap-4 mt-2 text-xs text-gray-500">
{v.primaryContact.name && (
<span className="flex items-center gap-1">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
{v.primaryContact.name}
</span>
)}
{v.primaryContact.email && (
<span className="flex items-center gap-1">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
{v.primaryContact.email}
</span>
)}
</div>
{/* Risk + Meta row */}
<div className="flex items-center gap-3 mt-2 flex-wrap">
<span className={`px-2 py-0.5 text-xs rounded-full ${riskColor(v.inherentRiskScore)}`}>
Inherent: {v.inherentRiskScore}
</span>
<span className={`px-2 py-0.5 text-xs rounded-full ${riskColor(v.residualRiskScore)}`}>
Residual: {v.residualRiskScore}
</span>
{v.updatedAt && (
<span className="text-xs text-gray-400">
Aktualisiert: {new Date(v.updatedAt).toLocaleDateString('de-DE')}
</span>
)}
</div>
{/* Third-country transfers */}
{thirdCountryLocations.length > 0 && (
<div className="mt-2">
<span className="text-xs font-medium text-amber-700 bg-amber-50 px-2 py-0.5 rounded">Drittlandtransfers:</span>
<span className="text-xs text-gray-600 ml-1">
{thirdCountryLocations.map(l => `${l.country}${l.region ? ` (${l.region})` : ''}`).join(', ')}
</span>
</div>
)}
{/* Certifications */}
{v.certifications.length > 0 && (
<div className="flex items-center gap-2 mt-2 flex-wrap">
{v.certifications.map((c, i) => (
<span key={i} className="px-2 py-0.5 text-xs rounded-full bg-blue-50 text-blue-700 border border-blue-200">
{c.type}{c.expirationDate ? ` (bis ${new Date(c.expirationDate).toLocaleDateString('de-DE')})` : ''}
</span>
))}
</div>
)}
</div>
<h4 className="text-base font-semibold text-gray-900">
Auftragsverarbeitung fuer: {r.controllerName || '(Verantwortlicher nicht angegeben)'}
</h4>
<div className="flex items-center gap-4 mt-1 text-xs text-gray-400">
<span>{r.processingCategories.length} Verarbeitungskategorien</span>
<span>{r.subProcessors.length} Unterauftragsverarbeiter</span>
<span>Aktualisiert: {new Date(r.updatedAt).toLocaleDateString('de-DE')}</span>
{/* Link to vendor register */}
<div className="ml-4 flex-shrink-0">
<a href="/sdk/vendor-compliance"
className="px-3 py-1.5 text-sm text-purple-600 hover:bg-purple-50 rounded-lg transition-colors inline-flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Im Vendor Register oeffnen
</a>
</div>
</div>
<div className="flex items-center gap-1 ml-4">
<button onClick={() => setEditingRecord(r)}
className="px-3 py-1.5 text-sm text-purple-600 hover:bg-purple-50 rounded-lg transition-colors">
Bearbeiten
</button>
<button onClick={() => { if (confirm('Eintrag loeschen?')) handleDelete(r.id) }}
className="px-2 py-1.5 text-sm text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
</div>
))}
)
})}
</div>
)}
</div>

View File

@@ -0,0 +1,414 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import type { Obligation, ObligationComplianceCheckResult } from '@/lib/sdk/obligations-compliance'
import {
buildObligationDocumentHtml,
createDefaultObligationDocumentOrgHeader,
type ObligationDocumentOrgHeader,
type ObligationDocumentRevision,
} from '@/lib/sdk/obligations-document'
// =============================================================================
// TYPES
// =============================================================================
interface ObligationDocumentTabProps {
obligations: Obligation[]
complianceResult: ObligationComplianceCheckResult | null
}
// =============================================================================
// COMPONENT
// =============================================================================
function ObligationDocumentTab({ obligations, complianceResult }: ObligationDocumentTabProps) {
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
const [orgHeader, setOrgHeader] = useState<ObligationDocumentOrgHeader>(() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('bp_obligation_document_orgheader')
if (saved) {
try { return JSON.parse(saved) } catch { /* ignore */ }
}
}
return createDefaultObligationDocumentOrgHeader()
})
const [revisions, setRevisions] = useState<ObligationDocumentRevision[]>(() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('bp_obligation_document_revisions')
if (saved) {
try { return JSON.parse(saved) } catch { /* ignore */ }
}
}
return []
})
// ---------------------------------------------------------------------------
// localStorage persistence
// ---------------------------------------------------------------------------
useEffect(() => {
localStorage.setItem('bp_obligation_document_orgheader', JSON.stringify(orgHeader))
}, [orgHeader])
useEffect(() => {
localStorage.setItem('bp_obligation_document_revisions', JSON.stringify(revisions))
}, [revisions])
// ---------------------------------------------------------------------------
// Computed values
// ---------------------------------------------------------------------------
const obligationCount = obligations.length
const completedCount = useMemo(() => {
return obligations.filter(o => o.status === 'completed').length
}, [obligations])
const distinctSources = useMemo(() => {
const sources = new Set(obligations.map(o => o.source || 'Sonstig'))
return sources.size
}, [obligations])
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
const handlePrintDocument = useCallback(() => {
const html = buildObligationDocumentHtml(
obligations,
orgHeader,
complianceResult,
revisions,
)
const printWindow = window.open('', '_blank')
if (printWindow) {
printWindow.document.write(html)
printWindow.document.close()
setTimeout(() => printWindow.print(), 300)
}
}, [obligations, orgHeader, complianceResult, revisions])
const handleDownloadDocumentHtml = useCallback(() => {
const html = buildObligationDocumentHtml(
obligations,
orgHeader,
complianceResult,
revisions,
)
const blob = new Blob([html], { type: 'text/html;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `Pflichtenregister-${orgHeader.organizationName || 'Organisation'}-${new Date().toISOString().split('T')[0]}.html`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}, [obligations, orgHeader, complianceResult, revisions])
const handleAddRevision = useCallback(() => {
setRevisions(prev => [...prev, {
version: String(prev.length + 1) + '.0',
date: new Date().toISOString().split('T')[0],
author: '',
changes: '',
}])
}, [])
const handleUpdateRevision = useCallback((index: number, field: keyof ObligationDocumentRevision, value: string) => {
setRevisions(prev => prev.map((r, i) => i === index ? { ...r, [field]: value } : r))
}, [])
const handleRemoveRevision = useCallback((index: number) => {
setRevisions(prev => prev.filter((_, i) => i !== index))
}, [])
const updateOrgHeader = useCallback((field: keyof ObligationDocumentOrgHeader, value: string) => {
setOrgHeader(prev => ({ ...prev, [field]: value }))
}, [])
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
return (
<div className="space-y-6">
{/* 1. Action Bar */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900">Pflichtenregister</h3>
<p className="text-sm text-gray-500 mt-1">
Auditfaehiges Dokument mit {obligationCount} Pflichten generieren
</p>
</div>
<div className="flex gap-3">
<button
onClick={handleDownloadDocumentHtml}
disabled={obligationCount === 0}
className="bg-white border border-gray-300 text-gray-700 hover:bg-gray-50 rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
HTML herunterladen
</button>
<button
onClick={handlePrintDocument}
disabled={obligationCount === 0}
className="bg-purple-600 text-white hover:bg-purple-700 rounded-lg px-4 py-2 text-sm font-medium transition-colors shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
Als PDF drucken
</button>
</div>
</div>
</div>
{/* 2. Org Header Form */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h4 className="text-base font-semibold text-gray-900 mb-4">Organisationsdaten</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Organisation</label>
<input
type="text"
value={orgHeader.organizationName}
onChange={e => updateOrgHeader('organizationName', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Branche</label>
<input
type="text"
value={orgHeader.industry}
onChange={e => updateOrgHeader('industry', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Datenschutzbeauftragter</label>
<input
type="text"
value={orgHeader.dpoName}
onChange={e => updateOrgHeader('dpoName', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">DSB-Kontakt</label>
<input
type="text"
value={orgHeader.dpoContact}
onChange={e => updateOrgHeader('dpoContact', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Verantwortlicher</label>
<input
type="text"
value={orgHeader.responsiblePerson}
onChange={e => updateOrgHeader('responsiblePerson', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Rechtsabteilung</label>
<input
type="text"
value={orgHeader.legalDepartment}
onChange={e => updateOrgHeader('legalDepartment', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Dokumentversion</label>
<input
type="text"
value={orgHeader.documentVersion}
onChange={e => updateOrgHeader('documentVersion', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Pruefintervall</label>
<input
type="text"
value={orgHeader.reviewInterval}
onChange={e => updateOrgHeader('reviewInterval', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Letzte Pruefung</label>
<input
type="date"
value={orgHeader.lastReviewDate}
onChange={e => updateOrgHeader('lastReviewDate', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Naechste Pruefung</label>
<input
type="date"
value={orgHeader.nextReviewDate}
onChange={e => updateOrgHeader('nextReviewDate', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
/>
</div>
</div>
</div>
{/* 3. Revisions Manager */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<h4 className="text-base font-semibold text-gray-900">Aenderungshistorie</h4>
<button
onClick={handleAddRevision}
className="text-sm text-purple-600 hover:text-purple-700 font-medium"
>
+ Version hinzufuegen
</button>
</div>
{revisions.length > 0 ? (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-2 text-gray-600 font-medium">Version</th>
<th className="text-left py-2 text-gray-600 font-medium">Datum</th>
<th className="text-left py-2 text-gray-600 font-medium">Autor</th>
<th className="text-left py-2 text-gray-600 font-medium">Aenderungen</th>
<th className="py-2"></th>
</tr>
</thead>
<tbody>
{revisions.map((revision, index) => (
<tr key={index} className="border-b border-gray-100">
<td className="py-2 pr-2">
<input
type="text"
value={revision.version}
onChange={e => handleUpdateRevision(index, 'version', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
/>
</td>
<td className="py-2 pr-2">
<input
type="date"
value={revision.date}
onChange={e => handleUpdateRevision(index, 'date', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
/>
</td>
<td className="py-2 pr-2">
<input
type="text"
value={revision.author}
onChange={e => handleUpdateRevision(index, 'author', e.target.value)}
placeholder="Name"
className="w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
/>
</td>
<td className="py-2 pr-2">
<input
type="text"
value={revision.changes}
onChange={e => handleUpdateRevision(index, 'changes', e.target.value)}
placeholder="Beschreibung der Aenderungen"
className="w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
/>
</td>
<td className="py-2 text-right">
<button
onClick={() => handleRemoveRevision(index)}
className="text-sm text-red-600 hover:text-red-700 font-medium"
>
Entfernen
</button>
</td>
</tr>
))}
</tbody>
</table>
) : (
<p className="text-sm text-gray-500 italic">
Noch keine Revisionen. Die erste Version wird automatisch im Dokument eingetragen.
</p>
)}
</div>
{/* 4. Document Preview */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h4 className="text-base font-semibold text-gray-900 mb-4">Dokument-Vorschau</h4>
{obligationCount === 0 ? (
<div className="text-center py-8">
<p className="text-gray-500">Erfassen Sie Pflichten, um das Pflichtenregister zu generieren.</p>
</div>
) : (
<div className="space-y-4">
{/* Cover preview */}
<div className="bg-purple-50 rounded-lg p-4 text-center">
<p className="text-purple-700 font-semibold text-lg">Pflichtenregister</p>
<p className="text-purple-600 text-sm">
Regulatorische Pflichten {orgHeader.organizationName || 'Organisation'}
</p>
<p className="text-purple-500 text-xs mt-1">
Version {orgHeader.documentVersion} | Stand: {new Date().toLocaleDateString('de-DE')}
</p>
</div>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
<div className="bg-gray-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-gray-900">{obligationCount}</p>
<p className="text-xs text-gray-500">Pflichten</p>
</div>
<div className="bg-green-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-green-700">{completedCount}</p>
<p className="text-xs text-gray-500">Abgeschlossen</p>
</div>
<div className="bg-purple-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-purple-700">{distinctSources}</p>
<p className="text-xs text-gray-500">Regulierungen</p>
</div>
<div className="bg-gray-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-gray-900">12</p>
<p className="text-xs text-gray-500">Sektionen</p>
</div>
<div className="bg-gray-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-gray-900">
{complianceResult ? complianceResult.score : '—'}
</p>
<p className="text-xs text-gray-500">Compliance-Score</p>
</div>
</div>
{/* 12 Sections list */}
<div>
<p className="text-sm font-medium text-gray-700 mb-2">12 Dokument-Sektionen:</p>
<ol className="list-decimal list-inside text-sm text-gray-600 space-y-1">
<li>Ziel und Zweck</li>
<li>Geltungsbereich</li>
<li>Methodik</li>
<li>Regulatorische Grundlagen</li>
<li>Pflichtenuebersicht</li>
<li>Detaillierte Pflichten</li>
<li>Verantwortlichkeiten</li>
<li>Fristen und Termine</li>
<li>Nachweisverzeichnis</li>
<li>Compliance-Status</li>
<li>Aenderungshistorie</li>
</ol>
</div>
</div>
)}
</div>
</div>
)
}
export { ObligationDocumentTab }

View File

@@ -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>

View File

@@ -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(),

View 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(),
}
}

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
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))
}

View File

@@ -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,
},
{

View File

@@ -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
// =============================================================================