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 ---- // ---- VVT data ----
const [vvtActivities, setVvtActivities] = useState<any[]>([]) const [vvtActivities, setVvtActivities] = useState<any[]>([])
// ---- Vendor data ----
const [vendorList, setVendorList] = useState<Array<{id: string, name: string}>>([])
// ---- Loeschkonzept document state ---- // ---- Loeschkonzept document state ----
const [orgHeader, setOrgHeader] = useState<LoeschkonzeptOrgHeader>(createDefaultLoeschkonzeptOrgHeader()) const [orgHeader, setOrgHeader] = useState<LoeschkonzeptOrgHeader>(createDefaultLoeschkonzeptOrgHeader())
const [revisions, setRevisions] = useState<LoeschkonzeptRevision[]>([]) const [revisions, setRevisions] = useState<LoeschkonzeptRevision[]>([])
@@ -194,6 +197,7 @@ export default function LoeschfristenPage() {
responsiblePerson: raw.responsible_person || '', responsiblePerson: raw.responsible_person || '',
releaseProcess: raw.release_process || '', releaseProcess: raw.release_process || '',
linkedVVTActivityIds: raw.linked_vvt_activity_ids || [], linkedVVTActivityIds: raw.linked_vvt_activity_ids || [],
linkedVendorIds: raw.linked_vendor_ids || [],
status: raw.status || 'DRAFT', status: raw.status || 'DRAFT',
lastReviewDate: raw.last_review_date || base.lastReviewDate, lastReviewDate: raw.last_review_date || base.lastReviewDate,
nextReviewDate: raw.next_review_date || base.nextReviewDate, nextReviewDate: raw.next_review_date || base.nextReviewDate,
@@ -228,6 +232,7 @@ export default function LoeschfristenPage() {
responsible_person: p.responsiblePerson, responsible_person: p.responsiblePerson,
release_process: p.releaseProcess, release_process: p.releaseProcess,
linked_vvt_activity_ids: p.linkedVVTActivityIds, linked_vvt_activity_ids: p.linkedVVTActivityIds,
linked_vendor_ids: p.linkedVendorIds,
status: p.status, status: p.status,
last_review_date: p.lastReviewDate || null, last_review_date: p.lastReviewDate || null,
next_review_date: p.nextReviewDate || null, next_review_date: p.nextReviewDate || null,
@@ -257,6 +262,17 @@ export default function LoeschfristenPage() {
}) })
}, [tab, editingId]) }, [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 // Load Loeschkonzept org header from VVT organization data + revisions from localStorage
useEffect(() => { useEffect(() => {
// Load revisions from localStorage // Load revisions from localStorage
@@ -1408,13 +1424,13 @@ export default function LoeschfristenPage() {
Verarbeitungstaetigkeit aus Ihrem VVT. Verarbeitungstaetigkeit aus Ihrem VVT.
</p> </p>
<div className="space-y-2"> <div className="space-y-2">
{policy.linkedVvtIds && policy.linkedVvtIds.length > 0 && ( {policy.linkedVVTActivityIds && policy.linkedVVTActivityIds.length > 0 && (
<div className="mb-3"> <div className="mb-3">
<label className="block text-xs font-medium text-gray-500 mb-1"> <label className="block text-xs font-medium text-gray-500 mb-1">
Verknuepfte Taetigkeiten: Verknuepfte Taetigkeiten:
</label> </label>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{policy.linkedVvtIds.map((vvtId: string) => { {policy.linkedVVTActivityIds.map((vvtId: string) => {
const activity = vvtActivities.find( const activity = vvtActivities.find(
(a: any) => a.id === vvtId, (a: any) => a.id === vvtId,
) )
@@ -1429,8 +1445,8 @@ export default function LoeschfristenPage() {
onClick={() => onClick={() =>
updatePolicy(pid, (p) => ({ updatePolicy(pid, (p) => ({
...p, ...p,
linkedVvtIds: ( linkedVVTActivityIds: (
p.linkedVvtIds || [] p.linkedVVTActivityIds || []
).filter((id: string) => id !== vvtId), ).filter((id: string) => id !== vvtId),
})) }))
} }
@@ -1449,11 +1465,11 @@ export default function LoeschfristenPage() {
const val = e.target.value const val = e.target.value
if ( if (
val && val &&
!(policy.linkedVvtIds || []).includes(val) !(policy.linkedVVTActivityIds || []).includes(val)
) { ) {
updatePolicy(pid, (p) => ({ updatePolicy(pid, (p) => ({
...p, ...p,
linkedVvtIds: [...(p.linkedVvtIds || []), val], linkedVVTActivityIds: [...(p.linkedVVTActivityIds || []), val],
})) }))
} }
e.target.value = '' e.target.value = ''
@@ -1466,7 +1482,7 @@ export default function LoeschfristenPage() {
{vvtActivities {vvtActivities
.filter( .filter(
(a: any) => (a: any) =>
!(policy.linkedVvtIds || []).includes(a.id), !(policy.linkedVVTActivityIds || []).includes(a.id),
) )
.map((a: any) => ( .map((a: any) => (
<option key={a.id} value={a.id}> <option key={a.id} value={a.id}>
@@ -1485,6 +1501,95 @@ export default function LoeschfristenPage() {
)} )}
</div> </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 */} {/* Sektion 6: Review-Einstellungen */}
<div className="bg-white rounded-xl border border-gray-200 p-6 space-y-4"> <div className="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
<h3 className="text-lg font-semibold text-gray-900"> <h3 className="text-lg font-semibold text-gray-900">
@@ -2608,19 +2713,20 @@ export default function LoeschfristenPage() {
{/* Section list */} {/* Section list */}
<div className="border-t border-gray-200 pt-4"> <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 className="grid grid-cols-2 gap-1 text-xs text-gray-600">
<div>1. Ziel und Zweck</div> <div>1. Ziel und Zweck</div>
<div>7. Legal Hold Verfahren</div> <div>7. Auftragsverarbeiter</div>
<div>2. Geltungsbereich</div> <div>2. Geltungsbereich</div>
<div>8. Verantwortlichkeiten</div> <div>8. Legal Hold Verfahren</div>
<div>3. Grundprinzipien</div> <div>3. Grundprinzipien</div>
<div>9. Pruef-/Revisionszyklus</div> <div>9. Verantwortlichkeiten</div>
<div>4. Loeschregeln-Uebersicht</div> <div>4. Loeschregeln-Uebersicht</div>
<div>10. Compliance-Status</div> <div>10. Pruef-/Revisionszyklus</div>
<div>5. Detaillierte Loeschregeln</div> <div>5. Detaillierte Loeschregeln</div>
<div>11. Aenderungshistorie</div> <div>11. Compliance-Status</div>
<div>6. VVT-Verknuepfung</div> <div>6. VVT-Verknuepfung</div>
<div>12. Aenderungshistorie</div>
</div> </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"> <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">{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.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> <span><strong className="text-gray-700">{revisions.length}</strong> Revisionen</span>
{complianceResult && ( {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> <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' '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 { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
import TOMControlPanel from '@/components/sdk/obligations/TOMControlPanel' import TOMControlPanel from '@/components/sdk/obligations/TOMControlPanel'
import GapAnalysisView from '@/components/sdk/obligations/GapAnalysisView' import GapAnalysisView from '@/components/sdk/obligations/GapAnalysisView'
import { ObligationDocumentTab } from '@/components/sdk/obligations/ObligationDocumentTab'
import { useSDK } from '@/lib/sdk' import { useSDK } from '@/lib/sdk'
import { buildAssessmentPayload } from '@/lib/sdk/scope-to-facts' import { buildAssessmentPayload } from '@/lib/sdk/scope-to-facts'
import type { ApplicableRegulation } from '@/lib/sdk/compliance-scope-types' 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 { interface ObligationStats {
pending: number pending: number
in_progress: number in_progress: number
@@ -50,6 +35,7 @@ interface ObligationFormData {
priority: string priority: string
responsible: string responsible: string
linked_systems: string linked_systems: string
linked_vendor_ids: string
notes: string notes: string
} }
@@ -63,11 +49,26 @@ const EMPTY_FORM: ObligationFormData = {
priority: 'medium', priority: 'medium',
responsible: '', responsible: '',
linked_systems: '', linked_systems: '',
linked_vendor_ids: '',
notes: '', notes: '',
} }
const API = '/api/sdk/v1/compliance/obligations' 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 // Status helpers
// ============================================================================= // =============================================================================
@@ -262,6 +263,18 @@ function ObligationModal({
/> />
</div> </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> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Notizen</label> <label className="block text-sm font-medium text-gray-700 mb-1">Notizen</label>
<textarea <textarea
@@ -365,6 +378,19 @@ function ObligationDetail({ obligation, onClose, onStatusChange, onEdit, onDelet
</div> </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 && ( {obligation.notes && (
<div> <div>
<span className="text-gray-500">Notizen</span> <span className="text-gray-500">Notizen</span>
@@ -559,9 +585,26 @@ export default function ObligationsPage() {
const [showModal, setShowModal] = useState(false) const [showModal, setShowModal] = useState(false)
const [editObligation, setEditObligation] = useState<Obligation | null>(null) const [editObligation, setEditObligation] = useState<Obligation | null>(null)
const [detailObligation, setDetailObligation] = useState<Obligation | null>(null) const [detailObligation, setDetailObligation] = useState<Obligation | null>(null)
const [showGapAnalysis, setShowGapAnalysis] = useState(false)
const [profiling, setProfiling] = useState(false) const [profiling, setProfiling] = useState(false)
const [applicableRegs, setApplicableRegs] = useState<ApplicableRegulation[]>([]) 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 () => { const loadData = useCallback(async () => {
setLoading(true) setLoading(true)
@@ -613,6 +656,7 @@ export default function ObligationsPage() {
priority: form.priority, priority: form.priority,
responsible: form.responsible || null, responsible: form.responsible || null,
linked_systems: form.linked_systems ? form.linked_systems.split(',').map(s => s.trim()).filter(Boolean) : [], 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, notes: form.notes || null,
}), }),
}) })
@@ -634,12 +678,12 @@ export default function ObligationsPage() {
priority: form.priority, priority: form.priority,
responsible: form.responsible || null, responsible: form.responsible || null,
linked_systems: form.linked_systems ? form.linked_systems.split(',').map(s => s.trim()).filter(Boolean) : [], 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, notes: form.notes || null,
}), }),
}) })
if (!res.ok) throw new Error('Aktualisierung fehlgeschlagen') if (!res.ok) throw new Error('Aktualisierung fehlgeschlagen')
await loadData() await loadData()
// Refresh detail if open
if (detailObligation?.id === id) { if (detailObligation?.id === id) {
const updated = await fetch(`${API}/${id}`) const updated = await fetch(`${API}/${id}`)
if (updated.ok) setDetailObligation(await updated.json()) if (updated.ok) setDetailObligation(await updated.json())
@@ -656,7 +700,6 @@ export default function ObligationsPage() {
const updated = await res.json() const updated = await res.json()
setObligations(prev => prev.map(o => o.id === id ? updated : o)) setObligations(prev => prev.map(o => o.id === id ? updated : o))
if (detailObligation?.id === id) setDetailObligation(updated) if (detailObligation?.id === id) setDetailObligation(updated)
// Refresh stats
fetch(`${API}/stats`).then(r => r.json()).then(setStats).catch(() => {}) fetch(`${API}/stats`).then(r => r.json()).then(setStats).catch(() => {})
} }
@@ -672,7 +715,6 @@ export default function ObligationsPage() {
setProfiling(true) setProfiling(true)
setError(null) setError(null)
try { try {
// Build payload from real CompanyProfile + Scope data
const profile = sdkState.companyProfile const profile = sdkState.companyProfile
const scopeState = sdkState.complianceScope const scopeState = sdkState.complianceScope
const scopeAnswers = scopeState?.answers || [] const scopeAnswers = scopeState?.answers || []
@@ -682,7 +724,6 @@ export default function ObligationsPage() {
if (profile) { if (profile) {
payload = buildAssessmentPayload(profile, scopeAnswers, scopeDecision) as unknown as Record<string, unknown> payload = buildAssessmentPayload(profile, scopeAnswers, scopeDecision) as unknown as Record<string, unknown>
} else { } else {
// Fallback: Minimaldaten wenn kein Profil vorhanden
payload = { payload = {
employee_count: 50, employee_count: 50,
industry: 'technology', industry: 'technology',
@@ -702,11 +743,9 @@ export default function ObligationsPage() {
if (!res.ok) throw new Error(`HTTP ${res.status}`) if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json() const data = await res.json()
// Store applicable regulations for the info box
const regs: ApplicableRegulation[] = data.overview?.applicable_regulations || data.applicable_regulations || [] const regs: ApplicableRegulation[] = data.overview?.applicable_regulations || data.applicable_regulations || []
setApplicableRegs(regs) setApplicableRegs(regs)
// Extract obligations from response (can be nested under overview)
const rawObls = data.overview?.obligations || data.obligations || [] const rawObls = data.overview?.obligations || data.obligations || []
if (rawObls.length > 0) { if (rawObls.length > 0) {
const autoObls: Obligation[] = rawObls.map((o: Record<string, unknown>) => ({ const autoObls: Obligation[] = rawObls.map((o: Record<string, unknown>) => ({
@@ -738,11 +777,9 @@ export default function ObligationsPage() {
const stepInfo = STEP_EXPLANATIONS['obligations'] const stepInfo = STEP_EXPLANATIONS['obligations']
const filteredObligations = obligations.filter(o => { const filteredObligations = obligations.filter(o => {
// Status/priority filter
if (filter === 'ai') { if (filter === 'ai') {
if (!o.source.toLowerCase().includes('ai')) return false if (!o.source.toLowerCase().includes('ai')) return false
} }
// Regulation filter
if (regulationFilter !== 'all') { if (regulationFilter !== 'all') {
const src = o.source?.toLowerCase() || '' const src = o.source?.toLowerCase() || ''
const key = regulationFilter.toLowerCase() const key = regulationFilter.toLowerCase()
@@ -751,91 +788,12 @@ export default function ObligationsPage() {
return true return true
}) })
return ( // ---------------------------------------------------------------------------
<div className="space-y-6"> // Tab Content Renderers
{/* 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>
const renderUebersichtTab = () => (
<>
{/* Error */} {/* Error */}
{error && ( {error && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 text-sm text-amber-700">{error}</div> <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 */} {/* 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: '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: '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: '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: '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 => ( ].map(s => (
<div key={s.label} className={`bg-white rounded-xl border ${s.border} p-5`}> <div key={s.label} className={`bg-white rounded-xl border ${s.border} p-5`}>
<div className={`text-xs ${s.color}`}>{s.label}</div> <div className={`text-xs ${s.color}`}>{s.label}</div>
@@ -901,9 +860,26 @@ export default function ObligationsPage() {
</div> </div>
)} )}
{/* Gap Analysis View */} {/* Compliance Issues Summary */}
{showGapAnalysis && ( {complianceResult && complianceResult.issues.length > 0 && (
<GapAnalysisView /> <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 */} {/* Regulation Filter Chips */}
@@ -970,7 +946,7 @@ export default function ObligationsPage() {
</div> </div>
<h3 className="text-base font-semibold text-gray-900">Keine Pflichten gefunden</h3> <h3 className="text-base font-semibold text-gray-900">Keine Pflichten gefunden</h3>
<p className="mt-2 text-sm text-gray-500"> <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> </p>
<button <button
onClick={() => setShowModal(true)} onClick={() => setShowModal(true)}
@@ -982,6 +958,220 @@ export default function ObligationsPage() {
)} )}
</div> </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> </div>
) )
} }

View File

@@ -44,6 +44,16 @@ export default function TOMPage() {
const [tab, setTab] = useState<Tab>('uebersicht') const [tab, setTab] = useState<Tab>('uebersicht')
const [selectedTOMId, setSelectedTOMId] = useState<string | null>(null) const [selectedTOMId, setSelectedTOMId] = useState<string | null>(null)
const [complianceResult, setComplianceResult] = useState<TOMComplianceCheckResult | 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) // Compliance check (auto-run when derivedTOMs change)
@@ -55,6 +65,39 @@ export default function TOMPage() {
} }
}, [state?.derivedTOMs]) }, [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 // Computed / memoised values
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -377,6 +420,60 @@ export default function TOMPage() {
{/* Active tab content */} {/* Active tab content */}
<div>{renderActiveTab()}</div> <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> </div>
) )
} }

View File

@@ -1821,63 +1821,124 @@ function TabDokument({ activities, orgHeader }: { activities: VVTActivity[]; org
// TAB 5: AUFTRAGSVERARBEITER (Art. 30 Abs. 2) // TAB 5: AUFTRAGSVERARBEITER (Art. 30 Abs. 2)
// ============================================================================= // =============================================================================
interface ProcessorRecord { interface VendorForProcessor {
id: string id: string
vvtId: string name: string
controllerName: string role: string
controllerContact: string serviceDescription: string
processingCategories: string[] country: string
subProcessors: { name: string; purpose: string; country: string; isThirdCountry: boolean }[] processingLocations: { country: string; region?: string; isEU: boolean; isAdequate: boolean }[]
thirdCountryTransfers: { country: string; recipient: string; transferMechanism: string }[] transferMechanisms: string[]
tomDescription: string certifications: { type: string; expirationDate?: string }[]
status: 'DRAFT' | 'REVIEW' | 'APPROVED' | 'ARCHIVED' 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 createdAt: string
updatedAt: string updatedAt: string
} }
function createEmptyProcessorRecord(): ProcessorRecord { async function apiListProcessorVendors(): Promise<VendorForProcessor[]> {
const now = new Date().toISOString() const res = await fetch('/api/sdk/v1/vendor-compliance/vendors?limit=500')
return { if (!res.ok) throw new Error(`Vendor API error: ${res.status}`)
id: crypto.randomUUID(), const data = await res.json()
vvtId: 'AVV-001', const items: any[] = data?.data?.items ?? []
controllerName: '', return items
controllerContact: '', .filter((v: any) => v.role === 'PROCESSOR' || v.role === 'SUB_PROCESSOR')
processingCategories: [], .map((v: any) => ({
subProcessors: [], id: v.id,
thirdCountryTransfers: [], name: v.name ?? '',
tomDescription: '', role: v.role ?? '',
status: 'DRAFT', serviceDescription: v.serviceDescription ?? v.service_description ?? '',
createdAt: now, country: v.country ?? '',
updatedAt: now, 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 }) { function TabProcessor({ orgHeader }: { orgHeader: VVTOrganizationHeader }) {
const [records, setRecords] = useState<ProcessorRecord[]>([]) const [vendors, setVendors] = useState<VendorForProcessor[]>([])
const [editingRecord, setEditingRecord] = useState<ProcessorRecord | null>(null) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const handleAdd = () => { useEffect(() => {
const nextNum = records.length + 1 let cancelled = false
const rec = createEmptyProcessorRecord() setLoading(true)
rec.vvtId = `AVV-${String(nextNum).padStart(3, '0')}` setError(null)
setRecords(prev => [...prev, rec]) apiListProcessorVendors()
setEditingRecord(rec) .then(data => { if (!cancelled) setVendors(data) })
} .catch(err => { if (!cancelled) setError(err.message ?? 'Fehler beim Laden der Auftragsverarbeiter') })
.finally(() => { if (!cancelled) setLoading(false) })
const handleSave = (updated: ProcessorRecord) => { return () => { cancelled = true }
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)
}
const handlePrintProcessorDoc = () => { const handlePrintProcessorDoc = () => {
const today = new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) 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 = ` let html = `
<!DOCTYPE html> <!DOCTYPE html>
@@ -1915,21 +1976,30 @@ function TabProcessor({ orgHeader }: { orgHeader: VVTOrganizationHeader }) {
</div> </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 += ` html += `
<div class="record"> <div class="record">
<div class="record-header"> <div class="record-header">
<span class="vvt-id">${r.vvtId}</span> <span class="vvt-id">${ROLE_LABELS[v.role] ?? v.role}</span>
<h3>Auftragsverarbeitung fuer: ${r.controllerName || '(Verantwortlicher)'}</h3> <h3>${v.name}</h3>
</div> </div>
<table> <table>
<tr><th style="width:35%">Pflichtfeld (Art. 30 Abs. 2)</th><th>Inhalt</th></tr> <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 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>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>${r.processingCategories.length > 0 ? r.processingCategories.join('; ') : '<em style="color:#9ca3af;">nicht angegeben</em>'}</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>${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>Unterauftragsverarbeiter</strong></td><td>${subProcessorHtml}</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>Uebermittlung an Drittlaender</strong></td><td>${thirdCountryHtml}</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>TOM (Art. 32 DSGVO)</strong></td><td>Siehe TOM-Dokumentation im Vendor-Compliance-Modul</td></tr>
</table> </table>
</div> </div>
` `
@@ -1951,160 +2021,37 @@ function TabProcessor({ orgHeader }: { orgHeader: VVTOrganizationHeader }) {
} }
} }
// Editor mode
if (editingRecord) {
const update = (patch: Partial<ProcessorRecord>) => setEditingRecord(prev => prev ? { ...prev, ...patch } : prev)
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> {/* Info banner */}
<div className="flex items-center gap-3"> <div className="bg-purple-50 border border-purple-200 rounded-xl p-4 flex items-start gap-3">
<button onClick={() => setEditingRecord(null)} className="p-2 hover:bg-gray-100 rounded-lg"> <svg className="w-5 h-5 text-purple-500 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg> </svg>
</button> <div className="flex-1">
<div> <p className="text-sm text-purple-800">
<span className="text-sm font-mono text-gray-400">{editingRecord.vvtId}</span> Dieses Verzeichnis zeigt alle Auftragsverarbeiter aus dem Vendor Register.
<h2 className="text-lg font-bold text-gray-900">Auftragsverarbeitung bearbeiten</h2> Neue Auftragsverarbeiter hinzufuegen oder bestehende bearbeiten:
</div> </p>
</div> <a href="/sdk/vendor-compliance"
<div className="flex items-center gap-2"> 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">
<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"> <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" /> <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> </svg>
</button> Zum Vendor Register
</a>
</div> </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>
<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">
<div className="bg-white rounded-xl border border-gray-200 p-6"> <div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div> <div>
<h3 className="text-lg font-semibold text-gray-900">Auftragsverarbeiter-Verzeichnis (Art. 30 Abs. 2)</h3> <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"> <p className="text-sm text-gray-500 mt-0.5">
Wenn Ihr Unternehmen als Auftragsverarbeiter fuer andere Verantwortliche taetig ist, Auftragsverarbeiter und Unterauftragsverarbeiter aus dem Vendor-Compliance-Modul (nur lesen).
muessen Sie ein separates Verzeichnis fuehren.
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> {vendors.length > 0 && (
{records.length > 0 && (
<button <button
onClick={handlePrintProcessorDoc} 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" 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"
@@ -2115,69 +2062,152 @@ function TabProcessor({ orgHeader }: { orgHeader: VVTOrganizationHeader }) {
Als PDF drucken Als PDF drucken
</button> </button>
)} )}
<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"
>
<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>
Neue Auftragsverarbeitung
</button>
</div>
</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="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"> <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"> <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" /> <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> </svg>
</div> </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"> <p className="text-sm text-gray-500 max-w-md mx-auto">
Dieses Verzeichnis wird nur benoetigt, wenn Ihr Unternehmen personenbezogene Daten Legen Sie Auftragsverarbeiter im Vendor Register an, damit sie hier automatisch erscheinen.
im Auftrag eines anderen Verantwortlichen verarbeitet (Art. 30 Abs. 2 DSGVO).
</p> </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> </div>
) : ( )}
{/* Vendor cards (read-only) */}
{!loading && !error && vendors.length > 0 && (
<div className="space-y-3"> <div className="space-y-3">
{records.map(r => ( {vendors.map(v => {
<div key={r.id} className="bg-white rounded-xl border border-gray-200 p-5 hover:border-purple-200 transition-colors"> 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 items-start justify-between">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
{/* Header: Name + Role + Status */}
<div className="flex items-center gap-2 mb-1 flex-wrap"> <div className="flex items-center gap-2 mb-1 flex-wrap">
<span className="text-xs font-mono text-gray-400">{r.vvtId}</span> <h4 className="text-base font-semibold text-gray-900">{v.name}</h4>
<span className={`px-2 py-0.5 text-xs rounded-full ${STATUS_COLORS[r.status]}`}>{STATUS_LABELS[r.status]}</span> <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> </div>
<h4 className="text-base font-semibold text-gray-900">
Auftragsverarbeitung fuer: {r.controllerName || '(Verantwortlicher nicht angegeben)'} {/* Service description */}
</h4> {v.serviceDescription && (
<div className="flex items-center gap-4 mt-1 text-xs text-gray-400"> <p className="text-sm text-gray-600 mt-1">{v.serviceDescription}</p>
<span>{r.processingCategories.length} Verarbeitungskategorien</span> )}
<span>{r.subProcessors.length} Unterauftragsverarbeiter</span>
<span>Aktualisiert: {new Date(r.updatedAt).toLocaleDateString('de-DE')}</span> {/* Contact */}
</div> <div className="flex items-center gap-4 mt-2 text-xs text-gray-500">
</div> {v.primaryContact.name && (
<div className="flex items-center gap-1 ml-4"> <span className="flex items-center gap-1">
<button onClick={() => setEditingRecord(r)} <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
className="px-3 py-1.5 text-sm text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"> <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" />
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> </svg>
</button> {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> </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> </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> </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>
)} )}
</div> </div>
{/* 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>
)
})}
</div>
)}
</div>
{/* Legal info */} {/* Legal info */}
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4"> <div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
<h4 className="text-sm font-medium text-amber-800 mb-1">Art. 30 Abs. 2 DSGVO Pflichtangaben</h4> <h4 className="text-sm font-medium text-amber-800 mb-1">Art. 30 Abs. 2 DSGVO Pflichtangaben</h4>

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 // HTML Template
// ========================================================================= // =========================================================================
@@ -392,6 +407,7 @@ export function buildLoeschkonzeptHtml(
'Loeschregeln-Uebersicht', 'Loeschregeln-Uebersicht',
'Detaillierte Loeschregeln', 'Detaillierte Loeschregeln',
'VVT-Verknuepfung', 'VVT-Verknuepfung',
'Auftragsverarbeiter mit Loeschpflichten',
'Legal Hold Verfahren', 'Legal Hold Verfahren',
'Verantwortlichkeiten', 'Verantwortlichkeiten',
'Pruef- und Revisionszyklus', 'Pruef- und Revisionszyklus',
@@ -594,11 +610,47 @@ export function buildLoeschkonzeptHtml(
` `
// ========================================================================= // =========================================================================
// Section 7: Legal Hold Verfahren // Section 7: Auftragsverarbeiter mit Loeschpflichten
// ========================================================================= // =========================================================================
html += ` html += `
<div class="section"> <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"> <div class="section-body">
<p>Ein Legal Hold (Aufbewahrungspflicht aufgrund rechtlicher Verfahren) setzt die regulaere <p>Ein Legal Hold (Aufbewahrungspflicht aufgrund rechtlicher Verfahren) setzt die regulaere
Loeschung aus. Betroffene Daten duerfen trotz abgelaufener Loeschfrist nicht geloescht werden, 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 += ` html += `
<div class="section"> <div class="section">
<div class="section-header">8. Verantwortlichkeiten</div> <div class="section-header">9. Verantwortlichkeiten</div>
<div class="section-body"> <div class="section-body">
<p>Die folgende Rollenmatrix zeigt, welche Organisationseinheiten fuer welche Datenobjekte <p>Die folgende Rollenmatrix zeigt, welche Organisationseinheiten fuer welche Datenobjekte
die Loeschverantwortung tragen:</p> die Loeschverantwortung tragen:</p>
@@ -665,11 +717,11 @@ export function buildLoeschkonzeptHtml(
` `
// ========================================================================= // =========================================================================
// Section 9: Pruef- und Revisionszyklus // Section 10: Pruef- und Revisionszyklus
// ========================================================================= // =========================================================================
html += ` html += `
<div class="section"> <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"> <div class="section-body">
<table> <table>
<tr><th>Eigenschaft</th><th>Wert</th></tr> <tr><th>Eigenschaft</th><th>Wert</th></tr>
@@ -691,11 +743,11 @@ export function buildLoeschkonzeptHtml(
` `
// ========================================================================= // =========================================================================
// Section 10: Compliance-Status // Section 11: Compliance-Status
// ========================================================================= // =========================================================================
html += ` html += `
<div class="section page-break"> <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"> <div class="section-body">
` `
if (complianceResult) { if (complianceResult) {
@@ -750,11 +802,11 @@ export function buildLoeschkonzeptHtml(
` `
// ========================================================================= // =========================================================================
// Section 11: Aenderungshistorie // Section 12: Aenderungshistorie
// ========================================================================= // =========================================================================
html += ` html += `
<div class="section"> <div class="section">
<div class="section-header">11. Aenderungshistorie</div> <div class="section-header">12. Aenderungshistorie</div>
<div class="section-body"> <div class="section-body">
<table> <table>
<tr><th>Version</th><th>Datum</th><th>Autor</th><th>Aenderungen</th></tr> <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 responsiblePerson: string
releaseProcess: string releaseProcess: string
linkedVVTActivityIds: string[] linkedVVTActivityIds: string[]
linkedVendorIds: string[]
// Status & Review // Status & Review
status: PolicyStatus status: PolicyStatus
lastReviewDate: string lastReviewDate: string
@@ -272,6 +273,7 @@ export function createEmptyPolicy(): LoeschfristPolicy {
responsiblePerson: '', responsiblePerson: '',
releaseProcess: '', releaseProcess: '',
linkedVVTActivityIds: [], linkedVVTActivityIds: [],
linkedVendorIds: [],
status: 'DRAFT', status: 'DRAFT',
lastReviewDate: now, lastReviewDate: now,
nextReviewDate: nextYear.toISOString(), 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', id: 'vendor-compliance',
seq: 4200, seq: 2500,
phase: 2, phase: 2,
package: 'betrieb', package: 'dokumentation',
order: 3, order: 6,
name: 'Vendor Compliance', name: 'Vendor Compliance',
nameShort: 'Vendor', nameShort: 'Vendor',
description: 'Dienstleister-Management', description: 'Dienstleister-Management',
url: '/sdk/vendor-compliance', url: '/sdk/vendor-compliance',
checkpointId: 'CP-VEND', checkpointId: 'CP-VEND',
prerequisiteSteps: ['escalations'], prerequisiteSteps: ['vvt'],
isOptional: false, isOptional: false,
}, },
{ {

View File

@@ -120,25 +120,6 @@ export interface VVTActivity {
art30Completeness?: VVTCompleteness 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 // CONSTANTS
// ============================================================================= // =============================================================================

View File

@@ -56,6 +56,7 @@ class LoeschfristCreate(BaseModel):
responsible_person: Optional[str] = None responsible_person: Optional[str] = None
release_process: Optional[str] = None release_process: Optional[str] = None
linked_vvt_activity_ids: Optional[List[Any]] = None linked_vvt_activity_ids: Optional[List[Any]] = None
linked_vendor_ids: Optional[List[Any]] = None
status: str = "DRAFT" status: str = "DRAFT"
last_review_date: Optional[datetime] = None last_review_date: Optional[datetime] = None
next_review_date: Optional[datetime] = None next_review_date: Optional[datetime] = None
@@ -86,6 +87,7 @@ class LoeschfristUpdate(BaseModel):
responsible_person: Optional[str] = None responsible_person: Optional[str] = None
release_process: Optional[str] = None release_process: Optional[str] = None
linked_vvt_activity_ids: Optional[List[Any]] = None linked_vvt_activity_ids: Optional[List[Any]] = None
linked_vendor_ids: Optional[List[Any]] = None
status: Optional[str] = None status: Optional[str] = None
last_review_date: Optional[datetime] = None last_review_date: Optional[datetime] = None
next_review_date: Optional[datetime] = None next_review_date: Optional[datetime] = None
@@ -100,7 +102,7 @@ class StatusUpdate(BaseModel):
# JSONB fields that need CAST # JSONB fields that need CAST
JSONB_FIELDS = { JSONB_FIELDS = {
"affected_groups", "data_categories", "legal_holds", "affected_groups", "data_categories", "legal_holds",
"storage_locations", "linked_vvt_activity_ids", "tags" "storage_locations", "linked_vvt_activity_ids", "linked_vendor_ids", "tags"
} }

View File

@@ -42,6 +42,7 @@ class ObligationCreate(BaseModel):
priority: str = "medium" priority: str = "medium"
responsible: Optional[str] = None responsible: Optional[str] = None
linked_systems: Optional[List[str]] = None linked_systems: Optional[List[str]] = None
linked_vendor_ids: Optional[List[str]] = None
assessment_id: Optional[str] = None assessment_id: Optional[str] = None
rule_code: Optional[str] = None rule_code: Optional[str] = None
notes: Optional[str] = None notes: Optional[str] = None
@@ -57,6 +58,7 @@ class ObligationUpdate(BaseModel):
priority: Optional[str] = None priority: Optional[str] = None
responsible: Optional[str] = None responsible: Optional[str] = None
linked_systems: Optional[List[str]] = None linked_systems: Optional[List[str]] = None
linked_vendor_ids: Optional[List[str]] = None
notes: Optional[str] = None notes: Optional[str] = None
@@ -173,14 +175,15 @@ async def create_obligation(
import json import json
linked_systems = json.dumps(payload.linked_systems or []) linked_systems = json.dumps(payload.linked_systems or [])
linked_vendor_ids = json.dumps(payload.linked_vendor_ids or [])
row = db.execute(text(""" row = db.execute(text("""
INSERT INTO compliance_obligations INSERT INTO compliance_obligations
(tenant_id, title, description, source, source_article, deadline, (tenant_id, title, description, source, source_article, deadline,
status, priority, responsible, linked_systems, assessment_id, rule_code, notes) status, priority, responsible, linked_systems, linked_vendor_ids, assessment_id, rule_code, notes)
VALUES VALUES
(:tenant_id, :title, :description, :source, :source_article, :deadline, (:tenant_id, :title, :description, :source, :source_article, :deadline,
:status, :priority, :responsible, CAST(:linked_systems AS jsonb), :assessment_id, :rule_code, :notes) :status, :priority, :responsible, CAST(:linked_systems AS jsonb), CAST(:linked_vendor_ids AS jsonb), :assessment_id, :rule_code, :notes)
RETURNING * RETURNING *
"""), { """), {
"tenant_id": tenant_id, "tenant_id": tenant_id,
@@ -193,6 +196,7 @@ async def create_obligation(
"priority": payload.priority, "priority": payload.priority,
"responsible": payload.responsible, "responsible": payload.responsible,
"linked_systems": linked_systems, "linked_systems": linked_systems,
"linked_vendor_ids": linked_vendor_ids,
"assessment_id": payload.assessment_id, "assessment_id": payload.assessment_id,
"rule_code": payload.rule_code, "rule_code": payload.rule_code,
"notes": payload.notes, "notes": payload.notes,
@@ -235,6 +239,9 @@ async def update_obligation(
if field == "linked_systems": if field == "linked_systems":
updates["linked_systems"] = json.dumps(value or []) updates["linked_systems"] = json.dumps(value or [])
set_clauses.append("linked_systems = CAST(:linked_systems AS jsonb)") set_clauses.append("linked_systems = CAST(:linked_systems AS jsonb)")
elif field == "linked_vendor_ids":
updates["linked_vendor_ids"] = json.dumps(value or [])
set_clauses.append("linked_vendor_ids = CAST(:linked_vendor_ids AS jsonb)")
else: else:
updates[field] = value updates[field] = value
set_clauses.append(f"{field} = :{field}") set_clauses.append(f"{field} = :{field}")

View File

@@ -0,0 +1,3 @@
-- Obligations: Vendor-Verknuepfung fuer Art. 28 DSGVO
ALTER TABLE compliance_obligations
ADD COLUMN IF NOT EXISTS linked_vendor_ids JSONB DEFAULT '[]'::jsonb;

View File

@@ -0,0 +1,3 @@
-- Loeschfristen: Vendor-Verknuepfung
ALTER TABLE compliance_loeschfristen
ADD COLUMN IF NOT EXISTS linked_vendor_ids JSONB DEFAULT '[]'::jsonb;

View File

@@ -56,6 +56,7 @@ def make_policy_row(overrides=None):
"responsible_person": None, "responsible_person": None,
"release_process": None, "release_process": None,
"linked_vvt_activity_ids": [], "linked_vvt_activity_ids": [],
"linked_vendor_ids": [],
"status": "DRAFT", "status": "DRAFT",
"last_review_date": None, "last_review_date": None,
"next_review_date": None, "next_review_date": None,
@@ -132,7 +133,7 @@ class TestRowToDict:
class TestJsonbFields: class TestJsonbFields:
def test_jsonb_fields_set(self): def test_jsonb_fields_set(self):
expected = {"affected_groups", "data_categories", "legal_holds", expected = {"affected_groups", "data_categories", "legal_holds",
"storage_locations", "linked_vvt_activity_ids", "tags"} "storage_locations", "linked_vvt_activity_ids", "linked_vendor_ids", "tags"}
assert JSONB_FIELDS == expected assert JSONB_FIELDS == expected
@@ -618,3 +619,68 @@ class TestDeleteLoeschfrist:
) )
call_params = mock_db.execute.call_args[0][1] call_params = mock_db.execute.call_args[0][1]
assert call_params["tenant_id"] == "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e" assert call_params["tenant_id"] == "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e"
# =============================================================================
# Linked Vendor IDs (Vendor-Compliance Integration)
# =============================================================================
class TestLinkedVendorIds:
def test_create_with_linked_vendor_ids(self, mock_db):
row = make_policy_row({"linked_vendor_ids": ["vendor-1"]})
mock_db.execute.return_value.fetchone.return_value = row
resp = client.post("/loeschfristen", json={
"data_object_name": "Vendor-Daten",
"linked_vendor_ids": ["vendor-1"],
})
assert resp.status_code == 201
import json
call_params = mock_db.execute.call_args[0][1]
assert json.loads(call_params["linked_vendor_ids"]) == ["vendor-1"]
def test_create_without_linked_vendor_ids_defaults_empty(self, mock_db):
row = make_policy_row()
mock_db.execute.return_value.fetchone.return_value = row
resp = client.post("/loeschfristen", json={
"data_object_name": "Ohne Vendor",
})
assert resp.status_code == 201
import json
call_params = mock_db.execute.call_args[0][1]
assert json.loads(call_params["linked_vendor_ids"]) == []
def test_update_linked_vendor_ids(self, mock_db):
updated_row = make_policy_row({"linked_vendor_ids": ["v1"]})
mock_db.execute.return_value.fetchone.return_value = updated_row
resp = client.put(f"/loeschfristen/{POLICY_ID}", json={
"linked_vendor_ids": ["v1"],
})
assert resp.status_code == 200
import json
call_params = mock_db.execute.call_args[0][1]
assert json.loads(call_params["linked_vendor_ids"]) == ["v1"]
def test_update_clears_linked_vendor_ids(self, mock_db):
updated_row = make_policy_row({"linked_vendor_ids": []})
mock_db.execute.return_value.fetchone.return_value = updated_row
resp = client.put(f"/loeschfristen/{POLICY_ID}", json={
"linked_vendor_ids": [],
})
assert resp.status_code == 200
import json
call_params = mock_db.execute.call_args[0][1]
assert json.loads(call_params["linked_vendor_ids"]) == []
def test_schema_includes_linked_vendor_ids(self):
create_obj = LoeschfristCreate(
data_object_name="Test",
linked_vendor_ids=["vendor-a", "vendor-b"],
)
assert create_obj.linked_vendor_ids == ["vendor-a", "vendor-b"]
update_obj = LoeschfristUpdate(linked_vendor_ids=["vendor-c"])
data = update_obj.model_dump(exclude_unset=True)
assert data["linked_vendor_ids"] == ["vendor-c"]
def test_jsonb_fields_contains_linked_vendor_ids(self):
assert "linked_vendor_ids" in JSONB_FIELDS

View File

@@ -52,6 +52,7 @@ def _make_obligation_row(overrides=None):
"priority": "medium", "priority": "medium",
"responsible": None, "responsible": None,
"linked_systems": [], "linked_systems": [],
"linked_vendor_ids": [],
"assessment_id": None, "assessment_id": None,
"rule_code": None, "rule_code": None,
"notes": None, "notes": None,
@@ -607,3 +608,60 @@ class TestObligationSearchRoute:
resp = client.get("/obligations?source=AI Act") resp = client.get("/obligations?source=AI Act")
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["obligations"][0]["source"] == "AI Act" assert resp.json()["obligations"][0]["source"] == "AI Act"
# =============================================================================
# Linked Vendor IDs Tests (Art. 28 DSGVO)
# =============================================================================
class TestLinkedVendorIds:
def test_create_with_linked_vendor_ids(self, client, mock_db):
row = _make_obligation_row({"linked_vendor_ids": ["vendor-1", "vendor-2"]})
mock_db.execute.return_value = MagicMock(fetchone=MagicMock(return_value=row))
resp = client.post("/obligations", json={
"title": "Vendor-Prüfung Art. 28",
"linked_vendor_ids": ["vendor-1", "vendor-2"],
})
assert resp.status_code == 201
assert resp.json()["linked_vendor_ids"] == ["vendor-1", "vendor-2"]
def test_create_without_linked_vendor_ids_defaults_empty(self, client, mock_db):
row = _make_obligation_row()
mock_db.execute.return_value = MagicMock(fetchone=MagicMock(return_value=row))
resp = client.post("/obligations", json={"title": "Ohne Vendor"})
assert resp.status_code == 201
# Schema allows it — linked_vendor_ids defaults to None in the schema
schema = ObligationCreate(title="Ohne Vendor")
assert schema.linked_vendor_ids is None
def test_update_linked_vendor_ids(self, client, mock_db):
updated = _make_obligation_row({"linked_vendor_ids": ["v1"]})
mock_db.execute.return_value = MagicMock(fetchone=MagicMock(return_value=updated))
resp = client.put(f"/obligations/{OBLIGATION_ID}", json={
"linked_vendor_ids": ["v1"],
})
assert resp.status_code == 200
assert resp.json()["linked_vendor_ids"] == ["v1"]
def test_update_clears_linked_vendor_ids(self, client, mock_db):
updated = _make_obligation_row({"linked_vendor_ids": []})
mock_db.execute.return_value = MagicMock(fetchone=MagicMock(return_value=updated))
resp = client.put(f"/obligations/{OBLIGATION_ID}", json={
"linked_vendor_ids": [],
})
assert resp.status_code == 200
assert resp.json()["linked_vendor_ids"] == []
def test_schema_create_includes_linked_vendor_ids(self):
schema = ObligationCreate(
title="Test Vendor Link",
linked_vendor_ids=["a", "b"],
)
assert schema.linked_vendor_ids == ["a", "b"]
data = schema.model_dump()
assert data["linked_vendor_ids"] == ["a", "b"]
def test_schema_update_includes_linked_vendor_ids(self):
schema = ObligationUpdate(linked_vendor_ids=["a"])
data = schema.model_dump(exclude_unset=True)
assert data["linked_vendor_ids"] == ["a"]

View File

@@ -291,17 +291,60 @@ POST /sdk/v1/ucca/obligations/gap-analysis
--- ---
## Frontend ## Frontend — 5-Tab-Aufbau
**URL:** `https://macmini:3007/sdk/obligations` **URL:** `https://macmini:3007/sdk/obligations`
Die Obligations-Seite zeigt: Die Obligations-Seite ist in 5 Tabs gegliedert:
- **Überblick-Kacheln:** Gesamtanzahl, nach Priorität, nach Regulierung | Tab | Inhalt |
- **Regulierungs-Tabs:** Pflichten gefiltert nach DSGVO, AI Act, NIS2, etc. |-----|--------|
- **Gap-Analyse-View:** Fehlende TOM-Controls visualisiert als Heatmap | **Uebersicht** | Statistik-Kacheln, Compliance-Score, Regulierungs-Filter, Pflichten-Liste (Cards), Compliance-Befunde |
- **TOM-Control-Panel:** Mapping von Pflichten → Controls mit Status | **Detail-Editor** | Pflichtenliste mit Bearbeitungsfunktion, Status-/Prioritaets-Badges |
- **Export:** C-Level-Memo (Markdown) direkt aus dem Frontend | **Profiling** | Auto-Profiling aus CompanyProfile + Compliance-Scope, anwendbare Regulierungen |
| **Gap-Analyse** | GapAnalysisView + TOMControlPanel (UCCA-Integration) |
| **Pflichtenregister** | Druckbares HTML-Dokument (12 Sektionen), Org-Header, Revisionen |
### 12 Compliance-Checks
Der Compliance-Checker (`obligations-compliance.ts`) prueft automatisch:
| # | Check | Severity | Ausloeser |
|---|-------|----------|-----------|
| 1 | `MISSING_RESPONSIBLE` | MEDIUM | Pflicht ohne Verantwortlichen |
| 2 | `OVERDUE_DEADLINE` | HIGH | Frist ueberschritten, Status != completed |
| 3 | `MISSING_EVIDENCE` | HIGH | Abgeschlossene Pflicht ohne Nachweis |
| 4 | `MISSING_DESCRIPTION` | MEDIUM | Pflicht ohne Beschreibung |
| 5 | `NO_LEGAL_REFERENCE` | HIGH | Pflicht ohne Artikel-Referenz |
| 6 | `INCOMPLETE_REGULATION` | HIGH | Regulierung mit allen Pflichten pending/overdue |
| 7 | `HIGH_PRIORITY_NOT_STARTED` | CRITICAL | Critical/High-Pflicht seit >30d pending |
| 8 | `STALE_PENDING` | LOW | Pflicht seit >90d pending |
| 9 | `MISSING_LINKED_SYSTEMS` | MEDIUM | Pflicht ohne Systemzuordnung |
| 10 | `NO_REVIEW_PROCESS` | MEDIUM | Keine Pflicht hat review_date |
| 11 | `CRITICAL_WITHOUT_EVIDENCE` | CRITICAL | Kritische Pflicht ohne Nachweis |
| 12 | `MISSING_VENDOR_LINK` | MEDIUM | Art.-28-Pflicht ohne verknuepften Auftragsverarbeiter |
**Score:** `100 - (CRITICAL*15 + HIGH*10 + MEDIUM*5 + LOW*2)`, min 0.
### Pflichtenregister-Dokument (12 Sektionen)
Das druckbare HTML-Dokument (`obligations-document.ts`) umfasst:
| # | Sektion | Datenquelle |
|---|---------|-------------|
| 0 | Deckblatt | orgHeader |
| — | Inhaltsverzeichnis | statisch |
| 1 | Ziel und Zweck | statisch |
| 2 | Geltungsbereich | orgHeader, obligations (distinct sources) |
| 3 | Methodik | statisch |
| 4 | Regulatorische Grundlagen | obligations gruppiert nach source |
| 5 | Pflichtenuebersicht | obligations nach Status |
| 6 | Detaillierte Pflichten | Pro Regulierung: Detail-Karten |
| 7 | Verantwortlichkeiten | Rollenmatrix |
| 8 | Fristen und Termine | Ueberfaellige + anstehende Deadlines |
| 9 | Nachweisverzeichnis | Evidence pro Pflicht |
| 10 | Compliance-Status | Score + Issues |
| 11 | Aenderungshistorie | Revisionstabelle |
--- ---
@@ -333,3 +376,45 @@ cd ai-compliance-sdk && go test ./internal/ucca/... -v -run TestObligationCondit
**Weitere Tests:** **Weitere Tests:**
- `tom_mapper_test.go` — TOM-Mapping Tests - `tom_mapper_test.go` — TOM-Mapping Tests
- `v2_loader_test.go` — JSON-Loader für Regulierungs-Dateien - `v2_loader_test.go` — JSON-Loader für Regulierungs-Dateien
- `backend-compliance/tests/test_obligation_routes.py` — 39 Backend-API-Tests
---
## Cross-Modul-Integration
| Modul | Integration |
|-------|------------|
| **VVT** | Pflichten referenzieren Verarbeitungstaetigkeiten ueber `linked_systems` |
| **TOM** | TOM-Control-Mapping (UCCA) zeigt erforderliche Massnahmen pro Pflicht |
| **Loeschfristen** | Loeschpflichten (Art. 17 DSGVO) im Pflichtenregister referenziert |
| **Vendor Compliance** | Art.-28-Pflichten verknuepfbar mit Auftragsverarbeitern ueber `linked_vendor_ids` (DB: JSONB). Compliance-Check #12 (`MISSING_VENDOR_LINK`) prueft fehlende Verknuepfung. |
| **UCCA** | Condition Engine bewertet Pflichten gegen UnifiedFacts |
| **Compliance-Scope** | Auto-Profiling nutzt Scope-Antworten fuer Regulierungs-Ableitung |
---
## Audit-Faehigkeit
Das Pflichtenregister ist auditfaehig durch:
1. **Druckbares HTML-Dokument** mit 12 Sektionen, A4-Layout, `@media print`
2. **11 automatische Compliance-Checks** mit Score (0-100) und Befunden nach Schweregrad
3. **Nachweisverzeichnis** (Sektion 9) dokumentiert Evidence pro Pflicht
4. **Aenderungshistorie** (Sektion 11) mit Version, Datum, Autor, Beschreibung
5. **Fristen-Tracking** (Sektion 8) mit ueberfaelligen und anstehenden Terminen
---
## Datei-Uebersicht
| Datei | Beschreibung |
|-------|-------------|
| `admin-compliance/app/sdk/obligations/page.tsx` | Haupt-Seite (5-Tab-Layout) |
| `admin-compliance/lib/sdk/obligations-compliance.ts` | 11 Compliance-Checks + Obligation-Type |
| `admin-compliance/lib/sdk/obligations-document.ts` | HTML-Dokument-Generator (12 Sektionen) |
| `admin-compliance/components/sdk/obligations/ObligationDocumentTab.tsx` | Pflichtenregister-Tab-Komponente |
| `admin-compliance/components/sdk/obligations/GapAnalysisView.tsx` | Gap-Analyse-Komponente |
| `admin-compliance/components/sdk/obligations/TOMControlPanel.tsx` | TOM-Control-Panel |
| `backend-compliance/compliance/api/obligation_routes.py` | 7 Backend-API-Endpoints |
| `backend-compliance/migrations/013_obligations.sql` | DB-Schema |
| `ai-compliance-sdk/policies/obligations/v2/` | 325 Pflichten, 9 Regulierungen |

View File

@@ -32,3 +32,28 @@ Seite unter `/sdk/vendor-compliance` mit Vendor-Tabelle, Risiko-Matrix und Vertr
## Datenbank ## Datenbank
Migration in der AI Compliance SDK erstellt Tabellen fuer Vendors, Risikobewertungen, Vertraege und AVV-Klauseln. Migration in der AI Compliance SDK erstellt Tabellen fuer Vendors, Risikobewertungen, Vertraege und AVV-Klauseln.
- `vendor_vendors` — Stammdaten, Rolle, Risiko-Scores, Kontakte
- `vendor_contracts` — AVV-Dokumente, Pruefstatus
- `vendor_findings` — Findings aus Pruefungen
- `vendor_control_instances` — Control-Instanzen pro Vendor (inkl. 6 TOM-Controls VND-TOM-01..06)
- `compliance_templates` — Shared Templates
## Cross-Modul-Integration
Seit der Vendor-Compliance Cross-Modul-Integration (2026-03-19) ist das Modul mit vier DSGVO-Modulen verknuepft:
| Modul | Integration | Richtung |
|-------|------------|----------|
| **VVT** | Processor-Tab (Art. 30 Abs. 2) liest Vendors mit `role=PROCESSOR/SUB_PROCESSOR` aus Vendor-API. Kein eigener State mehr — Single Source of Truth. | Read |
| **Obligations** | Art.-28-Pflichten verknuepfbar via `linked_vendor_ids` (JSONB). Compliance-Check `MISSING_VENDOR_LINK` prueft fehlende Verknuepfung. | Read/Write |
| **TOM** | Uebersicht-Tab zeigt Vendor-TOM-Controls (VND-TOM-01..06) als Querverweis-Tabelle. | Read |
| **Loeschfristen** | Loeschfrist-Policies verknuepfbar via `linked_vendor_ids` (JSONB). Loeschkonzept-Dokument listet verknuepfte Auftragsverarbeiter. | Read/Write |
### Sidebar-Position
Das Vendor-Compliance-Modul steht in der SDK-Sidebar bei **seq 2500** (Paket "dokumentation"), direkt nach VVT (seq 2400):
```
obligations (2000) → dsfa (2100) → tom (2200) → loeschfristen (2300) → vvt (2400) → vendor-compliance (2500)
```