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

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