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:
@@ -136,6 +136,9 @@ export default function LoeschfristenPage() {
|
||||
// ---- VVT data ----
|
||||
const [vvtActivities, setVvtActivities] = useState<any[]>([])
|
||||
|
||||
// ---- Vendor data ----
|
||||
const [vendorList, setVendorList] = useState<Array<{id: string, name: string}>>([])
|
||||
|
||||
// ---- Loeschkonzept document state ----
|
||||
const [orgHeader, setOrgHeader] = useState<LoeschkonzeptOrgHeader>(createDefaultLoeschkonzeptOrgHeader())
|
||||
const [revisions, setRevisions] = useState<LoeschkonzeptRevision[]>([])
|
||||
@@ -194,6 +197,7 @@ export default function LoeschfristenPage() {
|
||||
responsiblePerson: raw.responsible_person || '',
|
||||
releaseProcess: raw.release_process || '',
|
||||
linkedVVTActivityIds: raw.linked_vvt_activity_ids || [],
|
||||
linkedVendorIds: raw.linked_vendor_ids || [],
|
||||
status: raw.status || 'DRAFT',
|
||||
lastReviewDate: raw.last_review_date || base.lastReviewDate,
|
||||
nextReviewDate: raw.next_review_date || base.nextReviewDate,
|
||||
@@ -228,6 +232,7 @@ export default function LoeschfristenPage() {
|
||||
responsible_person: p.responsiblePerson,
|
||||
release_process: p.releaseProcess,
|
||||
linked_vvt_activity_ids: p.linkedVVTActivityIds,
|
||||
linked_vendor_ids: p.linkedVendorIds,
|
||||
status: p.status,
|
||||
last_review_date: p.lastReviewDate || null,
|
||||
next_review_date: p.nextReviewDate || null,
|
||||
@@ -257,6 +262,17 @@ export default function LoeschfristenPage() {
|
||||
})
|
||||
}, [tab, editingId])
|
||||
|
||||
// Load vendor list from API
|
||||
useEffect(() => {
|
||||
fetch('/api/sdk/v1/vendor-compliance/vendors?limit=500')
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => {
|
||||
const items = data?.data?.items || []
|
||||
setVendorList(items.map((v: any) => ({ id: v.id, name: v.name })))
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Load Loeschkonzept org header from VVT organization data + revisions from localStorage
|
||||
useEffect(() => {
|
||||
// Load revisions from localStorage
|
||||
@@ -1408,13 +1424,13 @@ export default function LoeschfristenPage() {
|
||||
Verarbeitungstaetigkeit aus Ihrem VVT.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{policy.linkedVvtIds && policy.linkedVvtIds.length > 0 && (
|
||||
{policy.linkedVVTActivityIds && policy.linkedVVTActivityIds.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||
Verknuepfte Taetigkeiten:
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{policy.linkedVvtIds.map((vvtId: string) => {
|
||||
{policy.linkedVVTActivityIds.map((vvtId: string) => {
|
||||
const activity = vvtActivities.find(
|
||||
(a: any) => a.id === vvtId,
|
||||
)
|
||||
@@ -1429,8 +1445,8 @@ export default function LoeschfristenPage() {
|
||||
onClick={() =>
|
||||
updatePolicy(pid, (p) => ({
|
||||
...p,
|
||||
linkedVvtIds: (
|
||||
p.linkedVvtIds || []
|
||||
linkedVVTActivityIds: (
|
||||
p.linkedVVTActivityIds || []
|
||||
).filter((id: string) => id !== vvtId),
|
||||
}))
|
||||
}
|
||||
@@ -1449,11 +1465,11 @@ export default function LoeschfristenPage() {
|
||||
const val = e.target.value
|
||||
if (
|
||||
val &&
|
||||
!(policy.linkedVvtIds || []).includes(val)
|
||||
!(policy.linkedVVTActivityIds || []).includes(val)
|
||||
) {
|
||||
updatePolicy(pid, (p) => ({
|
||||
...p,
|
||||
linkedVvtIds: [...(p.linkedVvtIds || []), val],
|
||||
linkedVVTActivityIds: [...(p.linkedVVTActivityIds || []), val],
|
||||
}))
|
||||
}
|
||||
e.target.value = ''
|
||||
@@ -1466,7 +1482,7 @@ export default function LoeschfristenPage() {
|
||||
{vvtActivities
|
||||
.filter(
|
||||
(a: any) =>
|
||||
!(policy.linkedVvtIds || []).includes(a.id),
|
||||
!(policy.linkedVVTActivityIds || []).includes(a.id),
|
||||
)
|
||||
.map((a: any) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
@@ -1485,6 +1501,95 @@ export default function LoeschfristenPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sektion 5b: Auftragsverarbeiter-Verknuepfung */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
5b. Verknuepfte Auftragsverarbeiter
|
||||
</h3>
|
||||
|
||||
{vendorList.length > 0 ? (
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-3">
|
||||
Verknuepfen Sie diese Loeschfrist mit relevanten Auftragsverarbeitern.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{policy.linkedVendorIds && policy.linkedVendorIds.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||
Verknuepfte Auftragsverarbeiter:
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{policy.linkedVendorIds.map((vendorId: string) => {
|
||||
const vendor = vendorList.find(
|
||||
(v) => v.id === vendorId,
|
||||
)
|
||||
return (
|
||||
<span
|
||||
key={vendorId}
|
||||
className="inline-flex items-center gap-1 bg-orange-100 text-orange-800 text-xs font-medium px-2 py-0.5 rounded-full"
|
||||
>
|
||||
{vendor?.name || vendorId}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
updatePolicy(pid, (p) => ({
|
||||
...p,
|
||||
linkedVendorIds: (
|
||||
p.linkedVendorIds || []
|
||||
).filter((id: string) => id !== vendorId),
|
||||
}))
|
||||
}
|
||||
className="text-orange-600 hover:text-orange-900"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<select
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
if (
|
||||
val &&
|
||||
!(policy.linkedVendorIds || []).includes(val)
|
||||
) {
|
||||
updatePolicy(pid, (p) => ({
|
||||
...p,
|
||||
linkedVendorIds: [...(p.linkedVendorIds || []), val],
|
||||
}))
|
||||
}
|
||||
e.target.value = ''
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
>
|
||||
<option value="">
|
||||
Auftragsverarbeiter verknuepfen...
|
||||
</option>
|
||||
{vendorList
|
||||
.filter(
|
||||
(v) =>
|
||||
!(policy.linkedVendorIds || []).includes(v.id),
|
||||
)
|
||||
.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.name || v.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400">
|
||||
Keine Auftragsverarbeiter gefunden. Erstellen Sie zuerst
|
||||
Auftragsverarbeiter im Vendor-Compliance-Modul, um hier Verknuepfungen
|
||||
herstellen zu koennen.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sektion 6: Review-Einstellungen */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
@@ -2608,19 +2713,20 @@ export default function LoeschfristenPage() {
|
||||
|
||||
{/* Section list */}
|
||||
<div className="border-t border-gray-200 pt-4">
|
||||
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">11 Sektionen</div>
|
||||
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">12 Sektionen</div>
|
||||
<div className="grid grid-cols-2 gap-1 text-xs text-gray-600">
|
||||
<div>1. Ziel und Zweck</div>
|
||||
<div>7. Legal Hold Verfahren</div>
|
||||
<div>7. Auftragsverarbeiter</div>
|
||||
<div>2. Geltungsbereich</div>
|
||||
<div>8. Verantwortlichkeiten</div>
|
||||
<div>8. Legal Hold Verfahren</div>
|
||||
<div>3. Grundprinzipien</div>
|
||||
<div>9. Pruef-/Revisionszyklus</div>
|
||||
<div>9. Verantwortlichkeiten</div>
|
||||
<div>4. Loeschregeln-Uebersicht</div>
|
||||
<div>10. Compliance-Status</div>
|
||||
<div>10. Pruef-/Revisionszyklus</div>
|
||||
<div>5. Detaillierte Loeschregeln</div>
|
||||
<div>11. Aenderungshistorie</div>
|
||||
<div>11. Compliance-Status</div>
|
||||
<div>6. VVT-Verknuepfung</div>
|
||||
<div>12. Aenderungshistorie</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2628,6 +2734,7 @@ export default function LoeschfristenPage() {
|
||||
<div className="border-t border-gray-200 pt-4 mt-4 flex gap-6 text-xs text-gray-500">
|
||||
<span><strong className="text-gray-700">{activePolicies.length}</strong> Loeschregeln</span>
|
||||
<span><strong className="text-gray-700">{policies.filter(p => p.linkedVVTActivityIds.length > 0).length}</strong> VVT-Verknuepfungen</span>
|
||||
<span><strong className="text-gray-700">{policies.filter(p => p.linkedVendorIds.length > 0).length}</strong> Vendor-Verknuepfungen</span>
|
||||
<span><strong className="text-gray-700">{revisions.length}</strong> Revisionen</span>
|
||||
{complianceResult && (
|
||||
<span>Compliance-Score: <strong className={complianceResult.score >= 75 ? 'text-green-600' : complianceResult.score >= 50 ? 'text-yellow-600' : 'text-red-600'}>{complianceResult.score}/100</strong></span>
|
||||
|
||||
@@ -1,35 +1,20 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
import TOMControlPanel from '@/components/sdk/obligations/TOMControlPanel'
|
||||
import GapAnalysisView from '@/components/sdk/obligations/GapAnalysisView'
|
||||
import { ObligationDocumentTab } from '@/components/sdk/obligations/ObligationDocumentTab'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import { buildAssessmentPayload } from '@/lib/sdk/scope-to-facts'
|
||||
import type { ApplicableRegulation } from '@/lib/sdk/compliance-scope-types'
|
||||
import type { Obligation, ObligationComplianceCheckResult } from '@/lib/sdk/obligations-compliance'
|
||||
import { runObligationComplianceCheck } from '@/lib/sdk/obligations-compliance'
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// Types (local only — Obligation imported from obligations-compliance.ts)
|
||||
// =============================================================================
|
||||
|
||||
interface Obligation {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
source: string
|
||||
source_article: string
|
||||
deadline: string | null
|
||||
status: 'pending' | 'in-progress' | 'completed' | 'overdue'
|
||||
priority: 'critical' | 'high' | 'medium' | 'low'
|
||||
responsible: string
|
||||
linked_systems: string[]
|
||||
assessment_id?: string
|
||||
rule_code?: string
|
||||
notes?: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
interface ObligationStats {
|
||||
pending: number
|
||||
in_progress: number
|
||||
@@ -50,6 +35,7 @@ interface ObligationFormData {
|
||||
priority: string
|
||||
responsible: string
|
||||
linked_systems: string
|
||||
linked_vendor_ids: string
|
||||
notes: string
|
||||
}
|
||||
|
||||
@@ -63,11 +49,26 @@ const EMPTY_FORM: ObligationFormData = {
|
||||
priority: 'medium',
|
||||
responsible: '',
|
||||
linked_systems: '',
|
||||
linked_vendor_ids: '',
|
||||
notes: '',
|
||||
}
|
||||
|
||||
const API = '/api/sdk/v1/compliance/obligations'
|
||||
|
||||
// =============================================================================
|
||||
// Tab definitions
|
||||
// =============================================================================
|
||||
|
||||
type Tab = 'uebersicht' | 'editor' | 'profiling' | 'gap-analyse' | 'pflichtenregister'
|
||||
|
||||
const TABS: { key: Tab; label: string }[] = [
|
||||
{ key: 'uebersicht', label: 'Uebersicht' },
|
||||
{ key: 'editor', label: 'Detail-Editor' },
|
||||
{ key: 'profiling', label: 'Profiling' },
|
||||
{ key: 'gap-analyse', label: 'Gap-Analyse' },
|
||||
{ key: 'pflichtenregister', label: 'Pflichtenregister' },
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// Status helpers
|
||||
// =============================================================================
|
||||
@@ -262,6 +263,18 @@ function ObligationModal({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Verknuepfte Auftragsverarbeiter</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.linked_vendor_ids}
|
||||
onChange={e => update('linked_vendor_ids', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="Kommagetrennt: Vendor-ID-1, Vendor-ID-2"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">IDs der Auftragsverarbeiter aus dem Vendor Register</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Notizen</label>
|
||||
<textarea
|
||||
@@ -365,6 +378,19 @@ function ObligationDetail({ obligation, onClose, onStatusChange, onEdit, onDelet
|
||||
</div>
|
||||
)}
|
||||
|
||||
{obligation.linked_vendor_ids && obligation.linked_vendor_ids.length > 0 && (
|
||||
<div>
|
||||
<span className="text-gray-500">Verknuepfte Auftragsverarbeiter</span>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{obligation.linked_vendor_ids.map(id => (
|
||||
<a key={id} href="/sdk/vendor-compliance" className="px-2 py-0.5 text-xs bg-indigo-50 text-indigo-700 rounded hover:bg-indigo-100 transition-colors">
|
||||
{id}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{obligation.notes && (
|
||||
<div>
|
||||
<span className="text-gray-500">Notizen</span>
|
||||
@@ -559,9 +585,26 @@ export default function ObligationsPage() {
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [editObligation, setEditObligation] = useState<Obligation | null>(null)
|
||||
const [detailObligation, setDetailObligation] = useState<Obligation | null>(null)
|
||||
const [showGapAnalysis, setShowGapAnalysis] = useState(false)
|
||||
const [profiling, setProfiling] = useState(false)
|
||||
const [applicableRegs, setApplicableRegs] = useState<ApplicableRegulation[]>([])
|
||||
const [activeTab, setActiveTab] = useState<Tab>('uebersicht')
|
||||
const [vendors, setVendors] = useState<Array<{id: string, name: string, role: string}>>([])
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/sdk/v1/vendor-compliance/vendors?limit=500')
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => {
|
||||
const items = data?.data?.items || []
|
||||
setVendors(items.map((v: any) => ({ id: v.id, name: v.name, role: v.role })))
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Compliance check result — auto-computed when obligations change
|
||||
const complianceResult = useMemo<ObligationComplianceCheckResult | null>(() => {
|
||||
if (obligations.length === 0) return null
|
||||
return runObligationComplianceCheck(obligations)
|
||||
}, [obligations])
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
@@ -613,6 +656,7 @@ export default function ObligationsPage() {
|
||||
priority: form.priority,
|
||||
responsible: form.responsible || null,
|
||||
linked_systems: form.linked_systems ? form.linked_systems.split(',').map(s => s.trim()).filter(Boolean) : [],
|
||||
linked_vendor_ids: form.linked_vendor_ids ? form.linked_vendor_ids.split(',').map(s => s.trim()).filter(Boolean) : [],
|
||||
notes: form.notes || null,
|
||||
}),
|
||||
})
|
||||
@@ -634,12 +678,12 @@ export default function ObligationsPage() {
|
||||
priority: form.priority,
|
||||
responsible: form.responsible || null,
|
||||
linked_systems: form.linked_systems ? form.linked_systems.split(',').map(s => s.trim()).filter(Boolean) : [],
|
||||
linked_vendor_ids: form.linked_vendor_ids ? form.linked_vendor_ids.split(',').map(s => s.trim()).filter(Boolean) : [],
|
||||
notes: form.notes || null,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) throw new Error('Aktualisierung fehlgeschlagen')
|
||||
await loadData()
|
||||
// Refresh detail if open
|
||||
if (detailObligation?.id === id) {
|
||||
const updated = await fetch(`${API}/${id}`)
|
||||
if (updated.ok) setDetailObligation(await updated.json())
|
||||
@@ -656,7 +700,6 @@ export default function ObligationsPage() {
|
||||
const updated = await res.json()
|
||||
setObligations(prev => prev.map(o => o.id === id ? updated : o))
|
||||
if (detailObligation?.id === id) setDetailObligation(updated)
|
||||
// Refresh stats
|
||||
fetch(`${API}/stats`).then(r => r.json()).then(setStats).catch(() => {})
|
||||
}
|
||||
|
||||
@@ -672,7 +715,6 @@ export default function ObligationsPage() {
|
||||
setProfiling(true)
|
||||
setError(null)
|
||||
try {
|
||||
// Build payload from real CompanyProfile + Scope data
|
||||
const profile = sdkState.companyProfile
|
||||
const scopeState = sdkState.complianceScope
|
||||
const scopeAnswers = scopeState?.answers || []
|
||||
@@ -682,7 +724,6 @@ export default function ObligationsPage() {
|
||||
if (profile) {
|
||||
payload = buildAssessmentPayload(profile, scopeAnswers, scopeDecision) as unknown as Record<string, unknown>
|
||||
} else {
|
||||
// Fallback: Minimaldaten wenn kein Profil vorhanden
|
||||
payload = {
|
||||
employee_count: 50,
|
||||
industry: 'technology',
|
||||
@@ -702,11 +743,9 @@ export default function ObligationsPage() {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
|
||||
// Store applicable regulations for the info box
|
||||
const regs: ApplicableRegulation[] = data.overview?.applicable_regulations || data.applicable_regulations || []
|
||||
setApplicableRegs(regs)
|
||||
|
||||
// Extract obligations from response (can be nested under overview)
|
||||
const rawObls = data.overview?.obligations || data.obligations || []
|
||||
if (rawObls.length > 0) {
|
||||
const autoObls: Obligation[] = rawObls.map((o: Record<string, unknown>) => ({
|
||||
@@ -738,11 +777,9 @@ export default function ObligationsPage() {
|
||||
const stepInfo = STEP_EXPLANATIONS['obligations']
|
||||
|
||||
const filteredObligations = obligations.filter(o => {
|
||||
// Status/priority filter
|
||||
if (filter === 'ai') {
|
||||
if (!o.source.toLowerCase().includes('ai')) return false
|
||||
}
|
||||
// Regulation filter
|
||||
if (regulationFilter !== 'all') {
|
||||
const src = o.source?.toLowerCase() || ''
|
||||
const key = regulationFilter.toLowerCase()
|
||||
@@ -751,91 +788,12 @@ export default function ObligationsPage() {
|
||||
return true
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Modals */}
|
||||
{(showModal || editObligation) && !detailObligation && (
|
||||
<ObligationModal
|
||||
initial={editObligation ? {
|
||||
title: editObligation.title,
|
||||
description: editObligation.description,
|
||||
source: editObligation.source,
|
||||
source_article: editObligation.source_article,
|
||||
deadline: editObligation.deadline ? editObligation.deadline.slice(0, 10) : '',
|
||||
status: editObligation.status,
|
||||
priority: editObligation.priority,
|
||||
responsible: editObligation.responsible,
|
||||
linked_systems: editObligation.linked_systems?.join(', ') || '',
|
||||
notes: editObligation.notes || '',
|
||||
} : undefined}
|
||||
onClose={() => { setShowModal(false); setEditObligation(null) }}
|
||||
onSave={async (form) => {
|
||||
if (editObligation) {
|
||||
await handleUpdate(editObligation.id, form)
|
||||
setEditObligation(null)
|
||||
} else {
|
||||
await handleCreate(form)
|
||||
setShowModal(false)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{detailObligation && (
|
||||
<ObligationDetail
|
||||
obligation={detailObligation}
|
||||
onClose={() => setDetailObligation(null)}
|
||||
onStatusChange={handleStatusChange}
|
||||
onDelete={handleDelete}
|
||||
onEdit={() => {
|
||||
setEditObligation(detailObligation)
|
||||
setDetailObligation(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<StepHeader
|
||||
stepId="obligations"
|
||||
title={stepInfo?.title || 'Pflichten-Management'}
|
||||
description={stepInfo?.description || 'DSGVO & AI-Act Compliance-Pflichten verwalten'}
|
||||
explanation={stepInfo?.explanation || ''}
|
||||
tips={stepInfo?.tips || []}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleAutoProfiling}
|
||||
disabled={profiling}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50 transition-colors text-sm disabled:opacity-50"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
{profiling ? 'Profiling...' : 'Auto-Profiling'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowGapAnalysis(v => !v)}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors text-sm ${
|
||||
showGapAnalysis ? 'bg-purple-100 text-purple-700' : 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
Gap-Analyse
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Pflicht hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
</StepHeader>
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tab Content Renderers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const renderUebersichtTab = () => (
|
||||
<>
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 text-sm text-amber-700">{error}</div>
|
||||
@@ -872,12 +830,13 @@ export default function ObligationsPage() {
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
{[
|
||||
{ label: 'Ausstehend', value: stats?.pending ?? 0, color: 'text-gray-600', border: 'border-gray-200' },
|
||||
{ label: 'In Bearbeitung',value: stats?.in_progress ?? 0, color: 'text-blue-600', border: 'border-blue-200' },
|
||||
{ label: 'Ueberfaellig', value: stats?.overdue ?? 0, color: 'text-red-600', border: 'border-red-200' },
|
||||
{ label: 'Abgeschlossen', value: stats?.completed ?? 0, color: 'text-green-600', border: 'border-green-200'},
|
||||
{ label: 'Compliance-Score', value: complianceResult ? complianceResult.score : '—', color: 'text-purple-600', border: 'border-purple-200'},
|
||||
].map(s => (
|
||||
<div key={s.label} className={`bg-white rounded-xl border ${s.border} p-5`}>
|
||||
<div className={`text-xs ${s.color}`}>{s.label}</div>
|
||||
@@ -901,9 +860,26 @@ export default function ObligationsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gap Analysis View */}
|
||||
{showGapAnalysis && (
|
||||
<GapAnalysisView />
|
||||
{/* Compliance Issues Summary */}
|
||||
{complianceResult && complianceResult.issues.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-3">Compliance-Befunde ({complianceResult.issues.length})</h3>
|
||||
<div className="space-y-2">
|
||||
{complianceResult.issues.map((issue, i) => (
|
||||
<div key={i} className="flex items-start gap-3 text-sm">
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full flex-shrink-0 ${
|
||||
issue.severity === 'CRITICAL' ? 'bg-red-100 text-red-700' :
|
||||
issue.severity === 'HIGH' ? 'bg-orange-100 text-orange-700' :
|
||||
issue.severity === 'MEDIUM' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{issue.severity === 'CRITICAL' ? 'Kritisch' : issue.severity === 'HIGH' ? 'Hoch' : issue.severity === 'MEDIUM' ? 'Mittel' : 'Niedrig'}
|
||||
</span>
|
||||
<span className="text-gray-700">{issue.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Regulation Filter Chips */}
|
||||
@@ -970,7 +946,7 @@ export default function ObligationsPage() {
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-gray-900">Keine Pflichten gefunden</h3>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
Klicken Sie auf "Pflicht hinzufuegen", um die erste Compliance-Pflicht zu erfassen.
|
||||
Klicken Sie auf "Pflicht hinzufuegen", um die erste Compliance-Pflicht zu erfassen.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
@@ -982,6 +958,220 @@ export default function ObligationsPage() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
const renderEditorTab = () => (
|
||||
<>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-gray-900">Pflichten bearbeiten ({obligations.length})</h3>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-sm"
|
||||
>
|
||||
Pflicht hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
{loading && <p className="text-gray-500 text-sm">Lade...</p>}
|
||||
{!loading && obligations.length === 0 && (
|
||||
<p className="text-gray-500 text-sm">Noch keine Pflichten vorhanden. Erstellen Sie eine neue Pflicht oder nutzen Sie Auto-Profiling.</p>
|
||||
)}
|
||||
{!loading && obligations.length > 0 && (
|
||||
<div className="space-y-2 max-h-[60vh] overflow-y-auto">
|
||||
{obligations.map(o => (
|
||||
<div
|
||||
key={o.id}
|
||||
className="flex items-center justify-between p-3 border border-gray-100 rounded-lg hover:bg-gray-50 cursor-pointer"
|
||||
onClick={() => {
|
||||
setEditObligation(o)
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full flex-shrink-0 ${STATUS_COLORS[o.status]}`}>
|
||||
{STATUS_LABELS[o.status]}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full flex-shrink-0 ${PRIORITY_COLORS[o.priority]}`}>
|
||||
{PRIORITY_LABELS[o.priority]}
|
||||
</span>
|
||||
<span className="text-sm text-gray-900 truncate">{o.title}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<span className="text-xs text-gray-400">{o.source}</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setEditObligation(o) }}
|
||||
className="text-xs text-purple-600 hover:text-purple-700 font-medium"
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
const renderProfilingTab = () => (
|
||||
<>
|
||||
{error && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 text-sm text-amber-700">{error}</div>
|
||||
)}
|
||||
|
||||
{!sdkState.companyProfile && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 text-sm text-yellow-700">
|
||||
Kein Unternehmensprofil vorhanden. Auto-Profiling verwendet Beispieldaten.{' '}
|
||||
<a href="/sdk/company-profile" className="underline font-medium">Profil anlegen →</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 text-center">
|
||||
<div className="w-12 h-12 mx-auto bg-purple-50 rounded-full flex items-center justify-center mb-3">
|
||||
<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-gray-900">Auto-Profiling</h3>
|
||||
<p className="text-xs text-gray-500 mt-1 mb-4">
|
||||
Ermittelt automatisch anwendbare Regulierungen und Pflichten aus dem Unternehmensprofil und Compliance-Scope.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleAutoProfiling}
|
||||
disabled={profiling}
|
||||
className="px-5 py-2 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
{profiling ? 'Profiling laeuft...' : 'Auto-Profiling starten'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{applicableRegs.length > 0 && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||
<h3 className="text-sm font-semibold text-blue-900 mb-2">Anwendbare Regulierungen</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{applicableRegs.map(reg => (
|
||||
<span
|
||||
key={reg.id}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-white border border-blue-300 text-blue-800"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{reg.name}
|
||||
{reg.classification && <span className="text-blue-500">({reg.classification})</span>}
|
||||
<span className="text-blue-400">{reg.obligation_count} Pflichten</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
const renderGapAnalyseTab = () => (
|
||||
<GapAnalysisView />
|
||||
)
|
||||
|
||||
const renderPflichtenregisterTab = () => (
|
||||
<ObligationDocumentTab
|
||||
obligations={obligations}
|
||||
complianceResult={complianceResult}
|
||||
/>
|
||||
)
|
||||
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'uebersicht': return renderUebersichtTab()
|
||||
case 'editor': return renderEditorTab()
|
||||
case 'profiling': return renderProfilingTab()
|
||||
case 'gap-analyse': return renderGapAnalyseTab()
|
||||
case 'pflichtenregister': return renderPflichtenregisterTab()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Modals */}
|
||||
{(showModal || editObligation) && !detailObligation && (
|
||||
<ObligationModal
|
||||
initial={editObligation ? {
|
||||
title: editObligation.title,
|
||||
description: editObligation.description,
|
||||
source: editObligation.source,
|
||||
source_article: editObligation.source_article,
|
||||
deadline: editObligation.deadline ? editObligation.deadline.slice(0, 10) : '',
|
||||
status: editObligation.status,
|
||||
priority: editObligation.priority,
|
||||
responsible: editObligation.responsible,
|
||||
linked_systems: editObligation.linked_systems?.join(', ') || '',
|
||||
notes: editObligation.notes || '',
|
||||
} : undefined}
|
||||
onClose={() => { setShowModal(false); setEditObligation(null) }}
|
||||
onSave={async (form) => {
|
||||
if (editObligation) {
|
||||
await handleUpdate(editObligation.id, form)
|
||||
setEditObligation(null)
|
||||
} else {
|
||||
await handleCreate(form)
|
||||
setShowModal(false)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{detailObligation && (
|
||||
<ObligationDetail
|
||||
obligation={detailObligation}
|
||||
onClose={() => setDetailObligation(null)}
|
||||
onStatusChange={handleStatusChange}
|
||||
onDelete={handleDelete}
|
||||
onEdit={() => {
|
||||
setEditObligation(detailObligation)
|
||||
setDetailObligation(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<StepHeader
|
||||
stepId="obligations"
|
||||
title={stepInfo?.title || 'Pflichten-Management'}
|
||||
description={stepInfo?.description || 'DSGVO & AI-Act Compliance-Pflichten verwalten'}
|
||||
explanation={stepInfo?.explanation || ''}
|
||||
tips={stepInfo?.tips || []}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Pflicht hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
</StepHeader>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex gap-1 border-b border-gray-200">
|
||||
{TABS.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`px-4 py-2.5 text-sm font-medium transition-colors ${
|
||||
activeTab === tab.key
|
||||
? 'border-b-2 border-purple-500 text-purple-700'
|
||||
: 'text-gray-500 hover:text-gray-700 hover:border-b-2 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -44,6 +44,16 @@ export default function TOMPage() {
|
||||
const [tab, setTab] = useState<Tab>('uebersicht')
|
||||
const [selectedTOMId, setSelectedTOMId] = useState<string | null>(null)
|
||||
const [complianceResult, setComplianceResult] = useState<TOMComplianceCheckResult | null>(null)
|
||||
const [vendorControls, setVendorControls] = useState<Array<{
|
||||
vendorId: string
|
||||
vendorName: string
|
||||
controlId: string
|
||||
controlName: string
|
||||
domain: string
|
||||
status: string
|
||||
lastTestedAt?: string
|
||||
}>>([])
|
||||
const [vendorControlsLoading, setVendorControlsLoading] = useState(false)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compliance check (auto-run when derivedTOMs change)
|
||||
@@ -55,6 +65,39 @@ export default function TOMPage() {
|
||||
}
|
||||
}, [state?.derivedTOMs])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vendor controls cross-reference (fetch when overview tab is active)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
useEffect(() => {
|
||||
if (tab !== 'uebersicht') return
|
||||
setVendorControlsLoading(true)
|
||||
Promise.all([
|
||||
fetch('/api/sdk/v1/vendor-compliance/control-instances?limit=500').then(r => r.ok ? r.json() : null),
|
||||
fetch('/api/sdk/v1/vendor-compliance/vendors?limit=500').then(r => r.ok ? r.json() : null),
|
||||
]).then(([ciData, vendorData]) => {
|
||||
const instances = ciData?.data?.items || []
|
||||
const vendors = vendorData?.data?.items || []
|
||||
const vendorMap = new Map<string, string>()
|
||||
for (const v of vendors) {
|
||||
vendorMap.set(v.id, v.name)
|
||||
}
|
||||
// Filter for TOM-domain controls
|
||||
const tomControls = instances
|
||||
.filter((ci: any) => ci.domain === 'TOM' || ci.controlId?.startsWith('VND-TOM'))
|
||||
.map((ci: any) => ({
|
||||
vendorId: ci.vendorId || ci.vendor_id,
|
||||
vendorName: vendorMap.get(ci.vendorId || ci.vendor_id) || 'Unbekannt',
|
||||
controlId: ci.controlId || ci.control_id,
|
||||
controlName: ci.controlName || ci.control_name || ci.controlId || ci.control_id,
|
||||
domain: ci.domain || 'TOM',
|
||||
status: ci.status || 'UNKNOWN',
|
||||
lastTestedAt: ci.lastTestedAt || ci.last_tested_at,
|
||||
}))
|
||||
setVendorControls(tomControls)
|
||||
}).catch(() => {}).finally(() => setVendorControlsLoading(false))
|
||||
}, [tab])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Computed / memoised values
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -377,6 +420,60 @@ export default function TOMPage() {
|
||||
|
||||
{/* Active tab content */}
|
||||
<div>{renderActiveTab()}</div>
|
||||
|
||||
{/* Vendor-Controls cross-reference (only on overview tab) */}
|
||||
{tab === 'uebersicht' && vendorControls.length > 0 && (
|
||||
<div className="mt-6 bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-gray-900">Auftragsverarbeiter-Controls (Art. 28)</h3>
|
||||
<p className="text-sm text-gray-500 mt-0.5">TOM-relevante Controls aus dem Vendor Register</p>
|
||||
</div>
|
||||
<a href="/sdk/vendor-compliance" className="text-sm text-purple-600 hover:text-purple-700 font-medium">
|
||||
Zum Vendor Register →
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user