feat: Third-country transfer tab in Vendor Compliance module

New "Drittlandtransfers" tab in the Vendor Compliance sidebar:
- Aggregates all vendor processing locations with non-EU countries
- Traffic light system: green (EU/adequacy), yellow (SCC exists),
  red (no transfer mechanism)
- Stats cards: total, EU+adequate, third-country, action required
- Filter by status (all/OK/review/action required)
- Table with vendor name, country, mechanism, SCC status, TIA status
- "TIA erstellen" link to Document Generator for third-country vendors
- Help text explaining Schrems II / Art. 46 DSGVO requirements

Uses existing data model — no new API endpoints or DB tables needed:
- vendor_vendors.processingLocations (isEU, isAdequate)
- vendor_vendors.transferMechanisms
- vendor_contracts.documentType = 'SCC'

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-01 11:16:19 +02:00
parent 9f4c4abb84
commit 03c17987a1
2 changed files with 260 additions and 0 deletions
@@ -48,6 +48,15 @@ const navItems: NavItem[] = [
</svg>
),
},
{
href: '/sdk/vendor-compliance/transfers',
label: 'Drittlandtransfers',
icon: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
},
{
href: '/sdk/vendor-compliance/risks',
label: 'Risiken',
@@ -0,0 +1,251 @@
'use client'
import { useMemo, useState } from 'react'
import { useVendorCompliance } from '@/lib/sdk/vendor-compliance'
import Link from 'next/link'
// ============================================================================
// Types
// ============================================================================
interface TransferEntry {
vendorId: string
vendorName: string
country: string
isEU: boolean
isAdequate: boolean
mechanisms: string[]
hasSCC: boolean
hasTIA: boolean
status: 'green' | 'yellow' | 'red'
statusLabel: string
}
// ============================================================================
// Helpers
// ============================================================================
function getTransferStatus(
isEU: boolean,
isAdequate: boolean,
mechanisms: string[],
hasSCC: boolean,
): { status: 'green' | 'yellow' | 'red'; label: string } {
if (isEU) return { status: 'green', label: 'EU/EWR' }
if (isAdequate) return { status: 'green', label: 'Angemessenheitsbeschluss' }
if (hasSCC && mechanisms.length > 0) return { status: 'yellow', label: 'SCC vorhanden' }
if (mechanisms.length > 0) return { status: 'yellow', label: 'Mechanismus vorhanden' }
return { status: 'red', label: 'Kein Transfermechanismus' }
}
const statusColors = {
green: 'bg-green-100 text-green-800',
yellow: 'bg-amber-100 text-amber-800',
red: 'bg-red-100 text-red-800',
}
const statusDots = {
green: 'bg-green-500',
yellow: 'bg-amber-500',
red: 'bg-red-500',
}
// ============================================================================
// Component
// ============================================================================
export default function TransfersPage() {
const { vendors, contracts } = useVendorCompliance()
const [filter, setFilter] = useState<'all' | 'green' | 'yellow' | 'red'>('all')
// Build transfer entries from vendors with non-EU processing locations
const transfers = useMemo<TransferEntry[]>(() => {
if (!vendors) return []
const entries: TransferEntry[] = []
for (const vendor of vendors) {
const locations = (vendor as Record<string, unknown>).processingLocations as Array<{
country: string; isEU: boolean; isAdequate: boolean
}> || []
const mechanisms = (vendor as Record<string, unknown>).transferMechanisms as string[] || []
// Check if vendor has any SCC contract
const vendorContracts = (contracts || []).filter(
(c: Record<string, unknown>) => c.vendorId === vendor.id
)
const hasSCC = vendorContracts.some(
(c: Record<string, unknown>) => c.documentType === 'SCC'
)
for (const loc of locations) {
const { status, label } = getTransferStatus(loc.isEU, loc.isAdequate, mechanisms, hasSCC)
entries.push({
vendorId: vendor.id,
vendorName: vendor.name,
country: loc.country,
isEU: loc.isEU,
isAdequate: loc.isAdequate,
mechanisms,
hasSCC,
hasTIA: false, // TODO: Check if TIA document exists for this vendor
status,
statusLabel: label,
})
}
}
return entries
}, [vendors, contracts])
// Filter
const filtered = filter === 'all'
? transfers
: transfers.filter((t) => t.status === filter)
// Stats
const stats = useMemo(() => ({
total: transfers.length,
eu: transfers.filter((t) => t.isEU).length,
adequate: transfers.filter((t) => !t.isEU && t.isAdequate).length,
thirdCountry: transfers.filter((t) => !t.isEU && !t.isAdequate).length,
red: transfers.filter((t) => t.status === 'red').length,
}), [transfers])
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-gray-900">Drittlandtransfers</h2>
<p className="text-sm text-gray-500 mt-1">
Uebersicht aller Datenuebermittlungen nach Verarbeitungsstandort. Art. 44-49 DSGVO.
</p>
</div>
{/* Stats */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-gray-200 p-4">
<div className="text-xs text-gray-500 uppercase tracking-wide">Gesamt</div>
<div className="text-2xl font-bold text-gray-900 mt-1">{stats.total}</div>
</div>
<div className="bg-white rounded-xl border border-green-200 p-4">
<div className="text-xs text-green-600 uppercase tracking-wide">EU/EWR + Adequat</div>
<div className="text-2xl font-bold text-green-700 mt-1">{stats.eu + stats.adequate}</div>
</div>
<div className="bg-white rounded-xl border border-amber-200 p-4">
<div className="text-xs text-amber-600 uppercase tracking-wide">Drittland (mit Mechanismus)</div>
<div className="text-2xl font-bold text-amber-700 mt-1">{stats.thirdCountry - stats.red}</div>
</div>
<div className="bg-white rounded-xl border border-red-200 p-4">
<div className="text-xs text-red-600 uppercase tracking-wide">Handlungsbedarf</div>
<div className="text-2xl font-bold text-red-700 mt-1">{stats.red}</div>
</div>
</div>
{/* Filter */}
<div className="flex gap-2">
{(['all', 'green', 'yellow', 'red'] as const).map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
filter === f
? 'bg-gray-900 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{f === 'all' && 'Alle'}
{f === 'green' && 'OK'}
{f === 'yellow' && 'Pruefen'}
{f === 'red' && 'Handlungsbedarf'}
</button>
))}
</div>
{/* Table */}
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="text-left px-4 py-3 font-medium text-gray-500">Status</th>
<th className="text-left px-4 py-3 font-medium text-gray-500">Vendor</th>
<th className="text-left px-4 py-3 font-medium text-gray-500">Land</th>
<th className="text-left px-4 py-3 font-medium text-gray-500">Mechanismus</th>
<th className="text-left px-4 py-3 font-medium text-gray-500">SCC</th>
<th className="text-left px-4 py-3 font-medium text-gray-500">TIA</th>
<th className="text-left px-4 py-3 font-medium text-gray-500">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{filtered.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-gray-400">
{transfers.length === 0
? 'Keine Vendors mit Verarbeitungsstandorten erfasst.'
: 'Keine Eintraege fuer den gewaehlten Filter.'}
</td>
</tr>
) : (
filtered.map((t, i) => (
<tr key={`${t.vendorId}-${t.country}-${i}`} className="hover:bg-gray-50">
<td className="px-4 py-3">
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${statusColors[t.status]}`}>
<span className={`w-2 h-2 rounded-full ${statusDots[t.status]}`} />
{t.statusLabel}
</span>
</td>
<td className="px-4 py-3 font-medium text-gray-900">
<Link
href={`/sdk/vendor-compliance/vendors?id=${t.vendorId}`}
className="hover:text-blue-600"
>
{t.vendorName}
</Link>
</td>
<td className="px-4 py-3 text-gray-600">
{t.country}
{t.isEU && <span className="ml-1 text-xs text-green-600">(EU)</span>}
</td>
<td className="px-4 py-3 text-gray-600">
{t.mechanisms.length > 0
? t.mechanisms.join(', ')
: <span className="text-red-500">Keiner</span>}
</td>
<td className="px-4 py-3">
{t.hasSCC
? <span className="text-green-600">Vorhanden</span>
: <span className="text-gray-400">-</span>}
</td>
<td className="px-4 py-3">
{t.hasTIA
? <span className="text-green-600">Vorhanden</span>
: !t.isEU && !t.isAdequate
? <span className="text-red-500">Fehlt</span>
: <span className="text-gray-400">-</span>}
</td>
<td className="px-4 py-3">
{!t.isEU && !t.isAdequate && (
<Link
href="/sdk/document-generator"
className="text-xs text-blue-600 hover:text-blue-800 font-medium"
>
TIA erstellen
</Link>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Help text */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-sm text-blue-800">
<strong>Hinweis:</strong> Fuer Datenuebermittlungen in Drittlaender ohne Angemessenheitsbeschluss sind
EU-Standardvertragsklauseln (SCC) und ein Transfer Impact Assessment (TIA) erforderlich (EuGH Schrems II, Art. 46 DSGVO).
Templates fuer SCC und TIA finden Sie im Document Generator unter der Kategorie "Drittlandtransfer".
</div>
</div>
)
}