03c17987a1
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>
252 lines
9.9 KiB
TypeScript
252 lines
9.9 KiB
TypeScript
'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>
|
|
)
|
|
}
|